fix(factory): open other agents' Factory requests as Droid (#519)

* fix(factory): open Anthropic requests with Droid's line

Factory only accepts a subscription request whose body opens as Droid
(#242, #506). Responses and chat completions already do. Anthropic
Messages now puts that line in the first system block, as droid2api
does, and keeps the agent's own prompt after it.

* fix(factory): serve Gemini on Factory's generate route

Every agent already reaches Factory's Claude, GPT and open models:
the gateway translates its own API into the model's, and the body
opens with Droid's line. Gemini was the wire still missing.
droid2api posts those models to /api/llm/g/v1/generate and prepends
the same line to systemInstruction. Factory now does that too.

The models are the ones droid 0.232.0's CLI registry still offers
(gemini-3.1-pro-preview, 3.8/3.7/3.6/3.5 Flash, gemini-3-flash-preview).
2.5 and Gemini 3 Pro Image are availableInCLI false there and stay out.
They are not Droid Core. A streamed Gemini reply comes back in the
client's own API.

TestFactoryFromEveryAgentWire posts Claude Code, Codex, Pi, OpenCode
and Gemini CLI at Claude, GPT, GLM and Gemini. TestFactoryGeminiStreamsToChat
reads a Gemini SSE chunk back as chat completions. Not tried against
a real Factory account.

* fix(factory): open other agents with droid2api's system prompt

droid2api's config.json system_prompt is Droid's line and a blank line,
written in front of the agent's own prompt on Responses, chat, Messages,
and Gemini generate. A body that already opens with the line is unchanged.

* fix(factory): answer non-streaming Gemini clients with JSON

Factory's generate route always returns SSE and droid sends no stream
field. A generateContent call is translated into JSON; a relayed
streamGenerateContent drops stream before it goes upstream. The provider
Test probes generate instead of failing the protocol, and a Gemini model
is tested on that route. The system prompt is joined into the one
systemInstruction part droid sends.

* fix(factory): join Droid's opening with one newline

droid writes the line and one newline in front of the agent's prompt.
The extra blank line from droid2api's system_prompt is dropped.
Anthropic still keeps the line as its own first block.

* fix(factory): send Droid's line alone when there is no prompt

droid joins blocks with "\n", so a request with no system prompt is
the line and nothing after it. Responses, chat and Gemini were
sending the line plus a trailing newline. Anthropic already sent
the line alone.
This commit is contained in:
Davy authored and GitHub committed 2026-10-03 03:39:54 +08:00
1 parent f0e4a79c06
commit 94cadbc34d
12 files changed
+595 -77

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// Every agent speaks one of the gateway's client APIs. droid2api covers the
// same four wires (Anthropic Messages, OpenAI Responses, chat completions,
// Gemini generateContent). Whichever of them asks for a Factory model, the
// body Factory receives opens with Droid's line and still carries the
// agent's own prompt. A Gemini model goes to Factory's generate route; the
// others stay on the wire droid sends that model on.
func TestFactoryFromEveryAgentWire(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
f := &fake{t: t, code: http.StatusForbidden, ctype: "application/json", reply: `{"error":{"message":"no"}}`}
up := httptest.NewServer(f)
t.Cleanup(up.Close)
t.Cleanup(provider.FactoryBaseForTest(up.URL, up.URL+"/eu"))
auth, _ := json.Marshal(map[string]any{
"accessToken": "tok", "refreshToken": "r",
"expiresAt": time.Now().Add(time.Hour).UnixMilli(),
"orgId": "org_D", "activeOrganizationId": "fac_D", "email": "d@example.com",
})
dir := filepath.Dir(provider.Path())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal([]map[string]any{{
"agent": "factory", "user": "d@example.com", "plan": "pro", "on": true, "auth": json.RawMessage(auth),
}})
if err := os.WriteFile(filepath.Join(dir, "logins.json"), raw, 0o600); err != nil {
t.Fatal(err)
}
const line = "You are Droid, an AI software engineering agent built by Factory."
ask := func(path, body, own, wantPath, wantProvider string) {
t.Helper()
f.got, f.path, f.head = nil, "", nil
post(t, path, body)
if f.path != wantPath {
t.Fatalf("%s went to %s, body %s", path, f.path, f.got)
}
if got := f.head.Get("x-api-provider"); got != wantProvider {
t.Errorf("%s x-api-provider %q", path, got)
}
if !strings.Contains(string(f.got), line) || !strings.Contains(string(f.got), own) {
t.Errorf("opening or agent prompt missing from %s", f.got)
}
var m map[string]any
if json.Unmarshal(f.got, &m) != nil {
t.Fatalf("upstream %s", f.got)
}
head := openingOf(m)
if !strings.HasPrefix(head, line) {
t.Errorf("opening %q\n%s", head, f.got)
}
}
// Claude Code, on each kind of Factory model.
ask("/v1/messages", `{"model":"factory/claude-opus-5-5","system":"You are Claude Code.","messages":[{"role":"user","content":"hi"}],"max_tokens":8}`,
"You are Claude Code.", "/api/llm/a/v1/messages", "anthropic")
ask("/v1/messages", `{"model":"factory/gpt-5.5","system":"You are Claude Code.","messages":[{"role":"user","content":"hi"}],"max_tokens":8}`,
"You are Claude Code.", "/api/llm/o/v1/responses", "openai")
ask("/v1/messages", `{"model":"factory/glm-5.3","system":"You are Claude Code.","messages":[{"role":"user","content":"hi"}],"max_tokens":8}`,
"You are Claude Code.", "/api/llm/o/v1/chat/completions", "fireworks")
ask("/v1/messages", `{"model":"factory/gemini-3.1-pro-preview","system":"You are Claude Code.","messages":[{"role":"user","content":"hi"}],"max_tokens":8}`,
"You are Claude Code.", "/api/llm/g/v1/generate", "google")
// Codex, and Pi (its system prompt is a developer item, not instructions).
ask("/v1/responses", `{"model":"factory/gpt-5.5","instructions":"You are Codex.","input":[{"role":"user","content":"hi"}]}`,
"You are Codex.", "/api/llm/o/v1/responses", "openai")
ask("/v1/responses", `{"model":"factory/claude-opus-5-5","instructions":"You are Codex.","input":[{"role":"user","content":"hi"}]}`,
"You are Codex.", "/api/llm/a/v1/messages", "anthropic")
ask("/v1/responses", `{"model":"factory/gemini-3.8-flash","instructions":"You are Codex.","input":[{"role":"user","content":"hi"}]}`,
"You are Codex.", "/api/llm/g/v1/generate", "google")
ask("/v1/responses", `{"model":"factory/gpt-5.5","input":[{"role":"developer","content":"You are Pi."},{"role":"user","content":"hi"}]}`,
"You are Pi.", "/api/llm/o/v1/responses", "openai")
// OpenCode and Crush, on chat completions.
ask("/v1/chat/completions", `{"model":"factory/glm-5.3","messages":[{"role":"system","content":"You are OpenCode."},{"role":"user","content":"hi"}]}`,
"You are OpenCode.", "/api/llm/o/v1/chat/completions", "fireworks")
ask("/v1/chat/completions", `{"model":"factory/claude-opus-5-5","messages":[{"role":"system","content":"You are OpenCode."},{"role":"user","content":"hi"}]}`,
"You are OpenCode.", "/api/llm/a/v1/messages", "anthropic")
ask("/v1/chat/completions", `{"model":"factory/gpt-5.5","messages":[{"role":"system","content":"You are OpenCode."},{"role":"user","content":"hi"}]}`,
"You are OpenCode.", "/api/llm/o/v1/responses", "openai")
ask("/v1/chat/completions", `{"model":"factory/gemini-3-flash-preview","messages":[{"role":"system","content":"You are OpenCode."},{"role":"user","content":"hi"}]}`,
"You are OpenCode.", "/api/llm/g/v1/generate", "google")
// Gemini CLI and Antigravity.
for _, model := range []string{"claude-opus-5-5", "gpt-5.5", "glm-5.3", "gemini-3.1-pro-preview"} {
want, prov := "/api/llm/a/v1/messages", "anthropic"
switch model {
case "gpt-5.5":
want, prov = "/api/llm/o/v1/responses", "openai"
case "glm-5.3":
want, prov = "/api/llm/o/v1/chat/completions", "fireworks"
case "gemini-3.1-pro-preview":
want, prov = "/api/llm/g/v1/generate", "google"
}
ask("/v1beta/models/factory/"+model+":generateContent",
`{"systemInstruction":{"parts":[{"text":"You are Gemini CLI."}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`,
"You are Gemini CLI.", want, prov)
}
}
// openingOf is the text a Factory body opens with, on whichever wire it took.
func openingOf(m map[string]any) string {
if s, ok := m["instructions"].(string); ok {
return s
}
switch s := m["system"].(type) {
case string:
return s
case []any:
if len(s) > 0 {
b, _ := s[0].(map[string]any)
t, _ := b["text"].(string)
return t
}
}
if si, ok := m["systemInstruction"].(map[string]any); ok {
parts, _ := si["parts"].([]any)
if len(parts) > 0 {
b, _ := parts[0].(map[string]any)
t, _ := b["text"].(string)
return t
}
}
msgs, _ := m["messages"].([]any)
if len(msgs) > 0 {
msg, _ := msgs[0].(map[string]any)
if msg["role"] == "system" {
switch c := msg["content"].(type) {
case string:
return c
case []any:
if len(c) > 0 {
b, _ := c[0].(map[string]any)
t, _ := b["text"].(string)
return t
}
}
}
}
return ""
}
// A chat client gets Gemini's streamed answer back as chat completions.
func TestFactoryGeminiStreamsToChat(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
f := &fake{t: t, reply: "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hello-from-factory\"}]},\"finishReason\":\"STOP\"}]}\n\n"}
up := httptest.NewServer(f)
t.Cleanup(up.Close)
t.Cleanup(provider.FactoryBaseForTest(up.URL, up.URL+"/eu"))
auth, _ := json.Marshal(map[string]any{
"accessToken": "tok", "refreshToken": "r",
"expiresAt": time.Now().Add(time.Hour).UnixMilli(),
"orgId": "org_D", "activeOrganizationId": "fac_D", "email": "d@example.com",
})
dir := filepath.Dir(provider.Path())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal([]map[string]any{{
"agent": "factory", "user": "d@example.com", "plan": "pro", "on": true, "auth": json.RawMessage(auth),
}})
if err := os.WriteFile(filepath.Join(dir, "logins.json"), raw, 0o600); err != nil {
t.Fatal(err)
}
code, body := post(t, "/v1/chat/completions", `{"model":"factory/gemini-3.1-pro-preview","stream":true,"messages":[{"role":"system","content":"You are OpenCode."},{"role":"user","content":"hi"}]}`)
if code != 200 || !strings.Contains(body, "hello-from-factory") {
t.Fatalf("chat %d %s", code, body)
}
if f.path != "/api/llm/g/v1/generate" || !strings.Contains(string(f.got), "You are OpenCode.") || strings.Contains(string(f.got), `"stream"`) {
t.Fatalf("upstream %s %s", f.path, f.got)
}
// generateContent asks for one JSON body. Factory answers SSE; the
// client gets the JSON, and the upstream body has no stream field.
code, body = post(t, "/v1beta/models/factory/gemini-3.1-pro-preview:generateContent", `{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
if code != 200 || strings.HasPrefix(body, "data:") || !strings.Contains(body, "hello-from-factory") {
t.Fatalf("json %d %s", code, body)
}
if strings.Contains(string(f.got), `"stream"`) {
t.Fatalf("stream sent upstream: %s", f.got)
}
code, body = post(t, "/v1beta/models/factory/gemini-3.1-pro-preview:streamGenerateContent", `{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
if code != 200 || !strings.Contains(body, "data:") || !strings.Contains(body, "hello-from-factory") {
t.Fatalf("sse %d %s", code, body)
}
if strings.Contains(string(f.got), `"stream"`) {
t.Fatalf("stream sent upstream: %s", f.got)
}
}
+28 -6
View File
@@ -1738,6 +1738,13 @@ func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.P
if p.OpenCodeFree(model) {
relay = false
}
// Factory's generate route always answers SSE, and droid sends no stream
// field. A client that asked for one JSON body is translated, which
// reads that SSE and writes the JSON. Relaying it would hand the client
// the data: lines under a 200.
if relay && from == provider.Gemini && !streamOf(body) {
relay = false
}
if relay {
call.To = from
if status, msg, done := s.passthrough(w, r, p, from, model, body, &call.Usage); done {
@@ -1983,6 +1990,11 @@ func (s *Server) passthrough(w http.ResponseWriter, r *http.Request, p provider.
// max_tokens away as Bedrock's GPT models do
body = asCompletionTokens(body)
}
case provider.Gemini:
// the handler put stream in the body so serve can tell a
// streamGenerateContent from a generateContent. droid's generate
// body has no such field, and Factory ignores it.
body = withoutFields(body, "stream")
case provider.Anthropic:
if p.IsBedrock() {
// Claude Code's metadata.user_id, a JSON string these days, is
@@ -2359,7 +2371,10 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to
r := *req
r.GeminiCompat, req = want, &r
}
body := build(to, req, model, p.Host(), p.RejectsTemperature(model))
body, err := build(to, req, model, p.Host(), p.RejectsTemperature(model))
if err != nil {
return nil, to, err
}
if to == provider.Chat && (p.IsBedrock() || p.IsAzure()) {
body = asCompletionTokens(body)
}
@@ -2837,6 +2852,9 @@ func pathOf(proto provider.Protocol) string {
return "/responses"
case provider.CodeAssist:
return "/v1internal:streamGenerateContent?alt=sse"
case provider.Gemini:
// Factory's generateContent. No other provider speaks Gemini upstream.
return "/generate"
}
return "/v1/messages"
}
@@ -2887,12 +2905,14 @@ func requiredAllowlist(proto provider.Protocol, body []byte) bool {
return false
}
func build(proto provider.Protocol, r *Request, model, host string, rejectTemp bool) []byte {
func build(proto provider.Protocol, r *Request, model, host string, rejectTemp bool) ([]byte, error) {
switch proto {
case provider.Chat:
return buildChat(r, model, host, rejectTemp)
return buildChat(r, model, host, rejectTemp), nil
case provider.Responses:
return buildResponses(r, model, host, rejectTemp)
return buildResponses(r, model, host, rejectTemp), nil
case provider.Gemini:
return buildGemini(r, model)
}
out := buildAnthropic(r, model)
if r.Fast && host == "api.anthropic.com" && provider.ClaudeFast(model) {
@@ -2900,7 +2920,7 @@ func build(proto provider.Protocol, r *Request, model, host string, rejectTemp b
// goes with it: forwardOnce)
out = withFields(out, map[string]any{"speed": "fast"})
}
return out
return out, nil
}
func decoder(proto provider.Protocol) func(data string, emit func(Event)) error {
@@ -2911,7 +2931,9 @@ func decoder(proto provider.Protocol) func(data string, emit func(Event)) error
case provider.Responses:
d := &responsesDecoder{}
return d.decode
case provider.CodeAssist:
case provider.CodeAssist, provider.Gemini:
// Factory's generateContent is the same Gemini chunks, without
// Code Assist's {response} wrapper, which the decoder also reads.
d := &codeAssistDecoder{}
return d.decode
}
+23
View File
@@ -82,6 +82,29 @@ type gRequest struct {
} `json:"generationConfig,omitempty"`
}
// buildGemini is a generateContent body for an upstream that speaks Gemini
// itself (Factory's /api/llm/g). The contents are the ones Code Assist
// sends inside its envelope. systemInstruction has no role: droid sends
// {parts:[{text}]}. droid sends no stream field; Factory answers SSE either
// way, and a client that asked for JSON is given it after the fact.
func buildGemini(r *Request, model string) ([]byte, error) {
var wrap struct {
Request map[string]any `json:"request"`
}
if err := json.Unmarshal(buildCodeAssistSent(r, model, "gemini"), &wrap); err != nil {
return nil, err
}
if wrap.Request == nil {
return nil, fmt.Errorf("empty Gemini request")
}
if si, ok := wrap.Request["systemInstruction"].(map[string]any); ok {
delete(si, "role")
}
wrap.Request["model"] = model
delete(wrap.Request, "stream")
return json.Marshal(wrap.Request)
}
func parseGemini(body []byte) (*Request, error) {
var g gRequest
if err := json.Unmarshal(body, &g); err != nil {
+5 -1
View File
@@ -252,7 +252,11 @@ func TestWithFastWire(t *testing.T) {
{"api.anthropic.com", "claude-sonnet-5", false},
{"relay.example", "claude-opus-5-5", false},
} {
got := strings.Contains(string(build(provider.Anthropic, r, c.model, c.host, false)), `"speed":"fast"`)
b, err := build(provider.Anthropic, r, c.model, c.host, false)
if err != nil {
t.Fatal(err)
}
got := strings.Contains(string(b), `"speed":"fast"`)
if got != c.want {
t.Errorf("%s %s: speed %v, want %v", c.host, c.model, got, c.want)
}
+19
View File
@@ -125,6 +125,25 @@ func (s *usageSniffer) parse(b []byte) {
}
}
switch s.proto {
case provider.Gemini:
// Factory's generateContent chunks, the same usageMetadata Code
// Assist wraps. Relaying one used to count nothing.
var v struct {
ModelVersion string `json:"modelVersion"`
UsageMetadata *struct {
Prompt int `json:"promptTokenCount"`
Candidates int `json:"candidatesTokenCount"`
Thoughts int `json:"thoughtsTokenCount"`
Cached int `json:"cachedContentTokenCount"`
} `json:"usageMetadata"`
}
if json.Unmarshal(b, &v) == nil {
s.saw(v.ModelVersion)
if u := v.UsageMetadata; u != nil {
s.u.add(Usage{Input: max(u.Prompt-u.Cached, 0), CacheRead: u.Cached,
Output: u.Candidates + u.Thoughts, Reasoning: u.Thoughts})
}
}
case provider.Chat:
var v struct {
Model string `json:"model"`
+10
View File
@@ -6,6 +6,16 @@ import (
"github.com/yetone/magpie/internal/provider"
)
func TestSniffGeminiUsage(t *testing.T) {
s := newSniffer(provider.Gemini, "text/event-stream")
s.write([]byte("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hi\"}]}}],\"usageMetadata\":{\"promptTokenCount\":100,\"candidatesTokenCount\":3,\"thoughtsTokenCount\":2,\"cachedContentTokenCount\":40},\"modelVersion\":\"gemini-3.1-pro-preview\"}\n\n"))
got := s.usage()
want := Usage{Input: 60, Output: 5, CacheRead: 40, Reasoning: 2, Served: "gemini-3.1-pro-preview"}
if got != want {
t.Fatalf("got %+v want %+v", got, want)
}
}
func TestSniffAnthropicStream(t *testing.T) {
s := newSniffer(provider.Anthropic, "text/event-stream")
s.write([]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"usage\":{\"input_tokens\":12,\"cache_read_input_tokens\":300,\"cache_creation_input_tokens\":40,\"output_tokens\":1}}}\n\n"))
+38 -22
View File
@@ -14,8 +14,9 @@ package provider
// (factory.ai: Pro, Plus, Max). Its models are served under Factory's own
// API, each on the wire it speaks natively: Claude on Anthropic's Messages
// at /api/llm/a, GPT and Grok on OpenAI's Responses at /api/llm/o/v1, the
// open models Factory hosts on chat completions beside it. The model field
// is Factory's own model id; the server picks the vendor behind it.
// open models Factory hosts on chat completions beside it, and Gemini on
// Google's generateContent at /api/llm/g/v1/generate. The model field is
// Factory's own model id; the server picks the vendor behind it.
//
// The sign-in is droid's own: WorkOS's device flow under droid's client, run
// by magpie and kept in logins.json. droid's own login is encrypted with a
@@ -50,6 +51,17 @@ var (
factoryAPIEU = "https://api.eu.factory.ai"
)
// FactoryBaseForTest points Factory's API at api, and its EU region at eu,
// until the returned function runs. A provider built after the call uses
// them. Tests outside this package use it.
func FactoryBaseForTest(api, eu string) func() {
oldA, oldE := factoryAPI, factoryAPIEU
factoryAPI, factoryAPIEU = api, eu
return func() {
factoryAPI, factoryAPIEU = oldA, oldE
}
}
const (
// factoryClientID is droid's WorkOS client, production.
factoryClientID = "client_01HNM792M5G5G1A2THWPXKFMXB"
@@ -502,17 +514,11 @@ func factoryMendOrg(ctx context.Context, user string, status int, body []byte) b
}
// factoryExplain is what the user can do about a 403 Factory still answers
// once magpie has sent what droid sends. Factory takes a subscription's
// model requests only from Droid: in #242 the same account's Claude, GPT and
// GLM models answered droid through magpie every time, with magpie's
// headers, and refused Grok Build's and Claude Code's every time, on the
// same models and efforts, a request's body (droid's system prompt opens
// with its own "You are Droid…") the only difference. Other agents'
// requests on /api/llm/o now open so too (factoryDroidBody), which no real
// account has tried; Claude's on /api/llm/a don't. So a 403 left says the
// line may not be all Factory checks, to use the model from Droid, and that
// an org's model policy or the plan is what is left when Droid is refused
// too.
// once the request opens as Droid's does (factoryDroidBody): the line first
// on Responses, chat completions, Anthropic's Messages and Gemini's
// generateContent. A 403 left is Factory telling the request apart some
// other way, or the organization's model policy or the plan, which refuses
// Droid too.
func factoryExplain(status int, body []byte) string {
if status != http.StatusForbidden {
return ""
@@ -520,7 +526,7 @@ func factoryExplain(status int, body []byte) string {
if factoryOrgRefused(status, body) {
return "the Factory account's organization changed; remove the account in magpie and sign in to it again"
}
return "Factory takes a Factory subscription's requests only from Droid itself. magpie opens other agents' requests to Factory's GPT, Grok and open models (GLM, Kimi…) as Droid's do, but Factory may still tell them apart, and Claude models (and MiniMax M2.7) are sent as the agent sent them, so Claude Code's and other agents' are refused there; use the model from Droid, and if Droid is refused it too, the organization's model policy or the plan doesn't allow this model"
return "Factory takes a Factory subscription's requests only from Droid itself. magpie already opens other agents' requests with Droid's line, and Factory may still tell them apart; use the model from Droid, and if Droid is refused it too, the organization's model policy or the plan doesn't allow this model"
}
// factoryFirstOrg is the first WorkOS org /api/cli/org says the account is
@@ -576,8 +582,10 @@ type factoryModel struct {
images bool
}
// factoryModels are the ones droid's /model picker offers, less Gemini's
// (sent on a route of Factory's own) and auto (droid picks it client side).
// factoryModels are the ones droid's /model picker offers, less auto (droid
// picks it client side). Gemini's are the ones droid 0.231.0's CLI registry
// still offers (provider google); 2.5 and Gemini 3 Pro Image are
// availableInCLI false there, and stay out. They go to /api/llm/g.
var factoryModels = []factoryModel{
{"claude-fable-5.1", "Fable 5.1", Anthropic, "anthropic", 867000, 128000, []string{"low", "medium", "high", "xhigh", "max"}, true},
{"claude-fable-5", "Fable 5", Anthropic, "anthropic", 867000, 128000, []string{"low", "medium", "high", "xhigh", "max"}, true},
@@ -599,6 +607,12 @@ var factoryModels = []factoryModel{
{"gpt-5.3-codex", "GPT-5.3-Codex", Responses, "openai", 400000, 128000, []string{"low", "medium", "high", "xhigh"}, true},
{"grok-4.7", "Grok 4.7", Responses, "xai", 500000, 63356, []string{"low", "medium", "high", "xhigh"}, true},
{"grok-4.6", "Grok 4.6", Responses, "xai", 200000, 63356, []string{"low", "medium", "high", "xhigh"}, true},
{"gemini-3.1-pro-preview", "Gemini 3.1 Pro", Gemini, "google", 1000000, 65536, []string{"low", "medium", "high"}, true},
{"gemini-3.8-flash", "Gemini 3.8 Flash", Gemini, "google", 1000000, 65536, []string{"low", "medium", "high"}, true},
{"gemini-3.7-flash", "Gemini 3.7 Flash", Gemini, "google", 1000000, 65536, []string{"low", "medium", "high"}, true},
{"gemini-3.6-flash", "Gemini 3.6 Flash", Gemini, "google", 1000000, 65536, []string{"low", "medium", "high"}, true},
{"gemini-3.5-flash", "Gemini 3.5 Flash", Gemini, "google", 1000000, 65536, []string{"minimal", "low", "medium", "high"}, true},
{"gemini-3-flash-preview", "Gemini 3 Flash", Gemini, "google", 1000000, 65536, []string{"minimal", "low", "medium", "high"}, true},
{"glm-5.3", "GLM-5.3", Chat, "fireworks", 1040000, 131072, []string{"low", "high", "max"}, false},
{"glm-5.3-flash", "GLM-5.3-Flash", Chat, "fireworks", 1048576, 131072, []string{"low", "high", "max"}, true},
{"glm-5.2", "GLM-5.2", Chat, "baseten", 1040000, 131072, []string{"high", "max"}, false},
@@ -613,10 +627,11 @@ var factoryModels = []factoryModel{
// factoryCore is whether a model is one of the open ones Factory hosts,
// billed to Droid Core; droid's registry has every one of them as provider
// "factory", served by Fireworks, Baseten or Mistral.
// "factory", served by Fireworks, Baseten or Mistral. Gemini is Google's,
// on the standard pool with Claude, GPT and Grok.
func factoryCore(id string) bool {
m, ok := factoryModelOf(id)
return ok && m.upstream != "anthropic" && m.upstream != "openai" && m.upstream != "xai"
return ok && m.upstream != "anthropic" && m.upstream != "openai" && m.upstream != "xai" && m.upstream != "google"
}
func factoryModelOf(id string) (factoryModel, bool) {
@@ -639,13 +654,13 @@ func factoryCatalog() []catalog.Model {
return out
}
// factoryAPIs is the API a model is served on; nil for one droid didn't
// list, which may be tried on any.
// factoryAPIs is the API a model is served on. One droid didn't list may be
// tried on the three wires the other models use, not on Gemini's generate.
func factoryAPIs(model string) []Protocol {
if m, ok := factoryModelOf(model); ok {
return []Protocol{m.api}
}
return nil
return []Protocol{Chat, Responses, Anthropic}
}
// ---- the provider ---------------------------------------------------------
@@ -685,7 +700,8 @@ func factoryProvider(a factoryLogin) Provider {
// which Anthropic's SDK sends beside the bearer token
req.Header.Set("X-Api-Key", "placeholder")
}
// another agent's request opens as droid's does (factoryDroidBody)
// another agent's request opens as droid's does (factoryDroidBody),
// on /api/llm/o, on Anthropic's Messages, and on Gemini's generate
if nb := factoryDroidBody(req.URL.Path, body); !bytes.Equal(nb, body) {
req.Body = io.NopCloser(bytes.NewReader(nb))
req.ContentLength = int64(len(nb))
+160 -25
View File
@@ -11,21 +11,19 @@ package provider
// mover's min in internal/provider/migrate_factory.go.
// Factory takes a subscription's model requests only from Droid (#242,
// #506): the same account's GPT, Grok and GLM answered Droid through magpie
// every time and refused Codex, Grok Build and Claude Code every time, on
// the same models, efforts, endpoint and headers, the body the only
// difference. Every request droid sends opens its system prompt with one
// line, droid's YOU_ARE_DROID_SYSTEM_PROMPT (droid 0.231.0: its system
// blocks are [that line, the agent's prompt, reminders…], sent as
// Responses' instructions joined with "\n", and on chat completions as one
// system message first, joined the same way; its smaller calls, the
// latency ping among them, send the line alone). So another agent's request
// to Factory's OpenAI-shaped API (/api/llm/o: GPT and Grok on Responses,
// the open models on chat completions) opens with that line too, the
// agent's own system prompt after it, as droid's own does. Anthropic's
// Messages (/api/llm/a: Claude, MiniMax M2.7) is left as the agent sent
// it, and so is a request that already opens with the line: droid's own
// goes on byte for byte.
// #506): the same account's models answered Droid through magpie and
// refused Codex, Grok Build and Claude Code, on the same models, efforts,
// endpoint and headers, the body the only difference. Every request droid
// sends opens its system prompt with one line, droid's
// YOU_ARE_DROID_SYSTEM_PROMPT (droid 0.231.0: its system blocks are [that
// line, the agent's prompt, reminders…]). Responses joins those blocks
// with "\n" into instructions, chat completions joins them into the first
// system message, Anthropic's Messages keeps them as blocks with the line
// first, and Gemini's generateContent joins them into the one
// systemInstruction part. Another agent's prompt follows, joined on with
// one newline, as droid joins its own blocks. With no prompt of its own
// the line stands alone: the "\n" is only between blocks. One that already
// opens with the line goes on byte for byte, and so does droid's own.
import (
"encoding/json"
@@ -35,12 +33,18 @@ import (
// factoryDroidLine is the line every droid system prompt opens with.
const factoryDroidLine = "You are Droid, an AI software engineering agent built by Factory."
// factoryDroidBody is body, a request to Factory's /api/llm/o at path, as
// droid would open it: Responses' instructions, or chat completions' first
// system message, starting with droid's line. Anything else, a body that
// can't be read, or one that already starts so is returned as it is.
// factoryDroidPrompt is the opening droid writes in front of another
// agent's prompt: the line, then one newline.
const factoryDroidPrompt = factoryDroidLine + "\n"
// factoryDroidBody is body, a request to Factory at path, as droid would
// open it. /api/llm/o: Responses' instructions, or chat completions' first
// system message. /api/llm/a: Anthropic's system blocks, the line first
// (factoryDroidMessages). /api/llm/g: Gemini's systemInstruction parts,
// the line first (factoryDroidGoogle). Anything else, a body that can't
// be read, or one that already starts so is returned as it is.
func factoryDroidBody(path string, body []byte) []byte {
if !strings.Contains(path, "/llm/o/") || len(body) == 0 {
if len(body) == 0 || (!strings.Contains(path, "/llm/o/") && !strings.Contains(path, "/llm/a/") && !strings.Contains(path, "/llm/g/")) {
return body
}
var m map[string]json.RawMessage
@@ -59,7 +63,7 @@ func factoryDroidBody(path string, body []byte) []byte {
if strings.TrimSpace(in) == "" {
in = factoryDroidLine
} else {
in = factoryDroidLine + "\n" + in
in = factoryDroidPrompt + in
}
m["instructions"], _ = zcodeEncode(in)
case strings.HasSuffix(path, "/chat/completions"):
@@ -74,6 +78,14 @@ func factoryDroidBody(path string, body []byte) []byte {
if m["messages"], err = zcodeEncode(msgs); err != nil {
return body
}
case strings.HasSuffix(path, "/messages"):
if !factoryDroidMessages(&m) {
return body
}
case strings.HasSuffix(path, "/generate"):
if !factoryDroidGoogle(&m) {
return body
}
default:
return body
}
@@ -97,13 +109,13 @@ func factoryDroidChat(msgs *[]map[string]any) bool {
if strings.TrimSpace(c) == "" {
ms[0]["content"] = factoryDroidLine
} else {
ms[0]["content"] = factoryDroidLine + "\n" + c
ms[0]["content"] = factoryDroidPrompt + c
}
return true
case []any:
// droid sends a string, its blocks joined with "\n": text
// parts alone are joined so
texts := []string{factoryDroidLine}
// parts alone are joined so, after the prompt
var texts []string
for i, p := range c {
p, _ := p.(map[string]any)
t, ok := p["text"].(string)
@@ -117,7 +129,12 @@ func factoryDroidChat(msgs *[]map[string]any) bool {
texts = append(texts, t)
}
if texts != nil {
ms[0]["content"] = strings.Join(texts, "\n")
joined := strings.Join(texts, "\n")
if strings.TrimSpace(joined) == "" {
ms[0]["content"] = factoryDroidLine
} else {
ms[0]["content"] = factoryDroidPrompt + joined
}
} else {
ms[0]["content"] = append([]any{map[string]any{"type": "text", "text": factoryDroidLine}}, c...)
}
@@ -127,3 +144,121 @@ func factoryDroidChat(msgs *[]map[string]any) bool {
*msgs = append([]map[string]any{{"role": "system", "content": factoryDroidLine}}, ms...)
return true
}
// factoryDroidMessages opens an Anthropic Messages body's system with
// droid's line as the first block, then the agent's prompt. A string system
// becomes those two blocks. False when it opens with the line already, or
// the field can't be read.
func factoryDroidMessages(m *map[string]json.RawMessage) bool {
raw, ok := (*m)["system"]
if !ok || string(raw) == "null" {
b, err := zcodeEncode([]any{map[string]any{"type": "text", "text": factoryDroidLine}})
if err != nil {
return false
}
(*m)["system"] = b
return true
}
var s string
if json.Unmarshal(raw, &s) == nil {
if strings.HasPrefix(s, factoryDroidLine) {
return false
}
blocks := []any{map[string]any{"type": "text", "text": factoryDroidLine}}
if strings.TrimSpace(s) != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": s})
}
b, err := zcodeEncode(blocks)
if err != nil {
return false
}
(*m)["system"] = b
return true
}
var blocks []any
if zcodeDecode(raw, &blocks) != nil {
return false
}
if len(blocks) > 0 {
if b, ok := blocks[0].(map[string]any); ok {
if t, _ := b["text"].(string); b["type"] == "text" && strings.HasPrefix(t, factoryDroidLine) {
return false
}
}
}
blocks = append([]any{map[string]any{"type": "text", "text": factoryDroidLine}}, blocks...)
b, err := zcodeEncode(blocks)
if err != nil {
return false
}
(*m)["system"] = b
return true
}
// factoryDroidGoogle opens a generateContent body's systemInstruction the
// way droid does, and the way Responses and chat do: one part, its text the
// prompt and then the agent's, joined. droid joins its own blocks with "\n"
// into that one part. With nothing of the agent's, the part is the line
// alone. A body that already opens with the line is left as it is. False
// when the field can't be read.
func factoryDroidGoogle(m *map[string]json.RawMessage) bool {
raw, ok := (*m)["systemInstruction"]
if !ok || string(raw) == "null" {
return factoryDroidGoogleText(m, nil, factoryDroidLine)
}
var s string
if json.Unmarshal(raw, &s) == nil {
if strings.HasPrefix(s, factoryDroidLine) {
return false
}
text := factoryDroidLine
if strings.TrimSpace(s) != "" {
text = factoryDroidPrompt + s
}
return factoryDroidGoogleText(m, nil, text)
}
var content map[string]any
if zcodeDecode(raw, &content) != nil {
return false
}
parts, _ := content["parts"].([]any)
if len(parts) > 0 {
if p, ok := parts[0].(map[string]any); ok {
if t, _ := p["text"].(string); strings.HasPrefix(t, factoryDroidLine) {
return false
}
}
}
var texts []string
var rest []any
for _, p := range parts {
pm, ok := p.(map[string]any)
t, tok := pm["text"].(string)
if ok && tok && len(pm) == 1 {
texts = append(texts, t)
continue
}
rest = append(rest, p)
}
text := factoryDroidLine
if joined := strings.Join(texts, "\n"); strings.TrimSpace(joined) != "" {
text = factoryDroidPrompt + joined
}
return factoryDroidGoogleText(m, content, text, rest...)
}
// factoryDroidGoogleText writes systemInstruction as one text part, then
// rest. content's other fields are kept when it is the instruction object
// already; nil starts a new one.
func factoryDroidGoogleText(m *map[string]json.RawMessage, content map[string]any, text string, rest ...any) bool {
if content == nil {
content = map[string]any{}
}
content["parts"] = append([]any{map[string]any{"text": text}}, rest...)
b, err := zcodeEncode(content)
if err != nil {
return false
}
(*m)["systemInstruction"] = b
return true
}
+74 -17
View File
@@ -15,11 +15,11 @@ import (
// #506: Codex on glm-5.3-flash through a Factory account got 403, as Grok
// Build's and Claude Code's requests did in #242, where Droid's own through
// magpie went through on the same models, endpoint and headers. droid
// 0.231.0 opens every system prompt with its line, sent as Responses'
// instructions and chat completions' first system message, each joined
// with "\n". Another agent's request to /api/llm/o now opens so, its own
// prompt after; droid's goes on byte for byte, and so does anything on
// Anthropic's Messages.
// 0.231.0 opens every system prompt with its line. Responses, chat and
// Gemini's generate join the agent's prompt on with one "\n". With no
// prompt of its own, the line stands alone. Anthropic's Messages takes
// the line as the first system block. droid's own request goes on byte
// for byte.
func TestFactoryOpensAsDroid(t *testing.T) {
signIn(t)
tok := factoryToken(map[string]any{"sub": "user_d", "org_id": "org_D"})
@@ -66,17 +66,22 @@ func TestFactoryOpensAsDroid(t *testing.T) {
return got[u.URL.Path]
}
chat, responses, messages := p.Chat+"/chat/completions", p.Responses+"/responses", p.Anthropic+"/v1/messages"
generate := p.Base(Gemini) + "/generate"
if !strings.HasSuffix(p.Base(Gemini), "/api/llm/g/v1") {
t.Fatalf("gemini base %s", p.Base(Gemini))
}
type msg struct {
Role string `json:"role"`
Content any `json:"content"`
}
read := func(b []byte) (out struct {
Model string `json:"model"`
Instructions *string `json:"instructions"`
Effort string `json:"reasoning_effort"`
Stream bool `json:"stream"`
Messages []msg `json:"messages"`
Input any `json:"input"`
Model string `json:"model"`
Instructions *string `json:"instructions"`
Effort string `json:"reasoning_effort"`
Stream bool `json:"stream"`
Messages []msg `json:"messages"`
Input any `json:"input"`
System json.RawMessage `json:"system"`
}) {
t.Helper()
if err := json.Unmarshal(b, &out); err != nil {
@@ -88,13 +93,13 @@ func TestFactoryOpensAsDroid(t *testing.T) {
// Codex on glm-5.3-flash, its Responses request made chat completions:
// its instructions as the first system message
b := read(send(chat, Chat, `{"model":"glm-5.3-flash","messages":[{"role":"system","content":"You are Codex, a coding agent."},{"role":"user","content":"hi <b> & co"}],"stream":true,"reasoning_effort":"high"}`))
if len(b.Messages) != 2 || b.Messages[0].Role != "system" || b.Messages[0].Content != factoryDroidLine+"\nYou are Codex, a coding agent." ||
if len(b.Messages) != 2 || b.Messages[0].Role != "system" || b.Messages[0].Content != factoryDroidPrompt+"You are Codex, a coding agent." ||
b.Messages[1].Content != "hi <b> & co" || b.Model != "glm-5.3-flash" || b.Effort != "high" || !b.Stream {
t.Errorf("codex on chat: %+v", b)
}
// Claude Code's system blocks, made chat completions as parts: joined
b = read(send(chat, Chat, `{"model":"kimi-k3","messages":[{"role":"system","content":[{"type":"text","text":"You are Claude Code."},{"type":"text","text":"Be brief."}]},{"role":"user","content":"hi"}]}`))
if len(b.Messages) != 2 || b.Messages[0].Content != factoryDroidLine+"\nYou are Claude Code.\nBe brief." {
if len(b.Messages) != 2 || b.Messages[0].Content != factoryDroidPrompt+"You are Claude Code.\nBe brief." {
t.Errorf("claude code on chat: %+v", b)
}
// no system prompt: droid's line alone, before the rest
@@ -104,7 +109,7 @@ func TestFactoryOpensAsDroid(t *testing.T) {
}
// Codex on GPT, Responses as it is: the instructions open with the line
b = read(send(responses, Responses, `{"model":"gpt-5.5","instructions":"You are Codex, a coding agent.","input":[{"role":"user","content":"hi"}],"stream":true}`))
if b.Instructions == nil || *b.Instructions != factoryDroidLine+"\nYou are Codex, a coding agent." || len(b.Input.([]any)) != 1 || !b.Stream {
if b.Instructions == nil || *b.Instructions != factoryDroidPrompt+"You are Codex, a coding agent." || len(b.Input.([]any)) != 1 || !b.Stream {
t.Errorf("codex on responses: %+v", b)
}
b = read(send(responses, Responses, `{"model":"grok-4.7","input":"hi"}`))
@@ -112,7 +117,52 @@ func TestFactoryOpensAsDroid(t *testing.T) {
t.Errorf("no instructions on responses: %+v", b)
}
// droid's own requests, and Anthropic's Messages, go on byte for byte
// Claude Code on Claude, a string system: two blocks, the line then its own
b = read(send(messages, Anthropic, `{"model":"claude-opus-5-5","system":"You are Claude Code.","messages":[{"role":"user","content":"hi"}]}`))
var blocks []map[string]any
if json.Unmarshal(b.System, &blocks) != nil || len(blocks) != 2 || blocks[0]["text"] != factoryDroidLine || blocks[1]["text"] != "You are Claude Code." ||
len(b.Messages) != 1 || b.Messages[0].Content != "hi" {
t.Errorf("claude code on messages: system %s messages %+v", b.System, b.Messages)
}
// Claude Code's own blocks stay after the line, cache control included
b = read(send(messages, Anthropic, `{"model":"claude-sonnet-5","system":[{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}},{"type":"text","text":"Be brief."}],"messages":[{"role":"user","content":"hi"}]}`))
blocks = nil
if json.Unmarshal(b.System, &blocks) != nil || len(blocks) != 3 || blocks[0]["text"] != factoryDroidLine || blocks[1]["text"] != "You are Claude Code." || blocks[2]["text"] != "Be brief." {
t.Errorf("claude code blocks: %s", b.System)
}
if cc, _ := blocks[1]["cache_control"].(map[string]any); cc["type"] != "ephemeral" {
t.Errorf("cache control: %v", blocks)
}
// no system prompt: the line alone
b = read(send(messages, Anthropic, `{"model":"minimax-m2.7","messages":[{"role":"user","content":"hi"}]}`))
blocks = nil
if json.Unmarshal(b.System, &blocks) != nil || len(blocks) != 1 || blocks[0]["text"] != factoryDroidLine || len(b.Messages) != 1 {
t.Errorf("no system on messages: system %s messages %+v", b.System, b.Messages)
}
// Gemini CLI on Factory's generate: one part, the prompt then its own text
sent := send(generate, Gemini, `{"model":"gemini-3.1-pro-preview","systemInstruction":{"parts":[{"text":"You are Gemini CLI."}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
var g struct {
SystemInstruction struct {
Parts []map[string]any `json:"parts"`
} `json:"systemInstruction"`
Contents []any `json:"contents"`
}
if json.Unmarshal(sent, &g) != nil || len(g.SystemInstruction.Parts) != 1 || g.SystemInstruction.Parts[0]["text"] != factoryDroidPrompt+"You are Gemini CLI." || len(g.Contents) != 1 {
t.Errorf("gemini cli on generate: %s", sent)
}
sent = send(generate, Gemini, `{"model":"gemini-3-flash-preview","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
g = struct {
SystemInstruction struct {
Parts []map[string]any `json:"parts"`
} `json:"systemInstruction"`
Contents []any `json:"contents"`
}{}
if json.Unmarshal(sent, &g) != nil || len(g.SystemInstruction.Parts) != 1 || g.SystemInstruction.Parts[0]["text"] != factoryDroidLine || len(g.Contents) != 1 {
t.Errorf("no system on generate: %s", sent)
}
// droid's own requests go on byte for byte
for _, c := range []struct {
url string
proto Protocol
@@ -120,8 +170,9 @@ func TestFactoryOpensAsDroid(t *testing.T) {
}{
{chat, Chat, `{"model":"glm-5.3-flash","messages":[{"role":"system","content":"` + factoryDroidLine + `\nYou work in the user's terminal."},{"role":"user","content":"hi"}],"stream":true,"n":1.0}`},
{responses, Responses, `{"model":"gpt-5.5","input":[],"store":false,"instructions":"` + factoryDroidLine + `\nYou work in the user's terminal.","stream":true}`},
{messages, Anthropic, `{"model":"claude-opus-5-5","system":"You are Claude Code.","messages":[{"role":"user","content":"hi"}]}`},
{messages, Anthropic, `{"model":"minimax-m2.7","messages":[{"role":"user","content":"hi"}]}`},
{messages, Anthropic, `{"model":"claude-opus-5-5","system":"` + factoryDroidLine + `\nYou are Claude Code.","messages":[{"role":"user","content":"hi"}]}`},
{messages, Anthropic, `{"model":"claude-opus-5-5","system":[{"type":"text","text":"` + factoryDroidLine + `"},{"type":"text","text":"Be brief."}],"messages":[{"role":"user","content":"hi"}]}`},
{generate, Gemini, `{"model":"gemini-3.1-pro-preview","systemInstruction":{"parts":[{"text":"` + factoryDroidLine + `\nYou are Gemini CLI."}]},"contents":[]}`},
} {
if sent := send(c.url, c.proto, c.body); !bytes.Equal(sent, []byte(c.body)) {
t.Errorf("changed:\n%s\nsent:\n%s", c.body, sent)
@@ -137,4 +188,10 @@ func TestFactoryGLMFlashImages(t *testing.T) {
if factoryVersion != "0.231.0" {
t.Errorf("factoryVersion %s", factoryVersion)
}
if m, ok := factoryModelOf("gemini-3.1-pro-preview"); !ok || m.api != Gemini || m.upstream != "google" || factoryCore("gemini-3.1-pro-preview") {
t.Errorf("gemini-3.1-pro-preview: %+v core %v", m, factoryCore("gemini-3.1-pro-preview"))
}
if !factoryCore("glm-5.3") {
t.Error("glm-5.3 is not Droid Core")
}
}
+3 -4
View File
@@ -702,11 +702,10 @@ func TestFactoryForbiddenWithoutOrg(t *testing.T) {
t.Errorf("prem request went to %s", got)
}
// a 403 still answered says what to do: Factory refuses every agent
// but Droid (#242: Claude Code on Opus 5.5, Grok Build on any model),
// which signing in again doesn't change; others pass as they are
// a 403 still answered says what to do: the request already opens as
// Droid (#242, #506), so signing in again changes nothing
msg := p.Explain("Factory: Forbidden", 403, []byte(forbidden))
if !strings.HasPrefix(msg, "Factory: Forbidden — ") || !strings.Contains(msg, "only from Droid") || !strings.Contains(msg, "Claude Code") ||
if !strings.HasPrefix(msg, "Factory: Forbidden — ") || !strings.Contains(msg, "only from Droid") || !strings.Contains(msg, "Droid's line") ||
strings.Contains(msg, "sign in to it again") {
t.Errorf("explained: %s", msg)
}
+12 -1
View File
@@ -31,7 +31,7 @@ const (
Chat Protocol = "chat" // OpenAI Chat Completions
Responses Protocol = "responses" // OpenAI Responses
Anthropic Protocol = "anthropic" // Anthropic Messages
Gemini Protocol = "gemini" // Google Gemini; only served to clients, never spoken upstream
Gemini Protocol = "gemini" // Google Gemini. Served to clients; spoken upstream only for Factory's generate route
)
// Protocols in the order magpie prefers them when it has to translate.
@@ -806,6 +806,12 @@ func (p Provider) Base(proto Protocol) string {
if p.Account != nil {
return p.Account.codeAssist
}
case Gemini:
// Factory's Gemini models are generateContent at /api/llm/g, not
// Code Assist. No other provider speaks Gemini upstream.
if p.ID == "factory" && p.Account != nil {
return factoryAPI + "/api/llm/g/v1"
}
}
return ""
}
@@ -826,6 +832,11 @@ func (p Provider) Speaks() []Protocol {
if p.IsPlugin() && p.Account.codeAssist != "" {
out = append(out, CodeAssist)
}
// Factory's Gemini models, on generateContent. A model droid didn't
// list stays on the other three (factoryAPIs); this is not one of them.
if p.ID == "factory" && p.Account != nil {
out = append(out, Gemini)
}
return out
}
+4 -1
View File
@@ -127,6 +127,9 @@ func tinyBody(q Provider, proto Protocol, model string) (url, body string) {
return q.Responses + "/responses", fmt.Sprintf(`{"model":%q,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],"max_output_tokens":16}`, model)
case Anthropic:
return q.Anthropic + "/v1/messages", fmt.Sprintf(`{"model":%q,"max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`, model)
case Gemini:
// Factory's generate route. droid sends no stream field.
return q.Base(Gemini) + "/generate", fmt.Sprintf(`{"model":%q,"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, model)
}
return "", ""
}
@@ -184,7 +187,7 @@ func (p Provider) testOne(ctx context.Context, model string) Result {
}
var protos []Protocol
for _, pr := range p.Speaks() {
if pr == Chat || pr == Responses || pr == Anthropic {
if pr == Chat || pr == Responses || pr == Anthropic || pr == Gemini {
protos = append(protos, pr)
}
}