diff --git a/docs/subsystems/agent-wiring.md b/docs/subsystems/agent-wiring.md index af912e47..fa7e85e6 100644 --- a/docs/subsystems/agent-wiring.md +++ b/docs/subsystems/agent-wiring.md @@ -46,6 +46,7 @@ something else shows up as drift. - Claude Code's tier and subagent models the user had set for their own endpoint (`claudeOwnEnv`) are stashed with that endpoint and token when magpie is wired in, and come back with them when magpie steps out, by Disconnect or by Claude Code's own model (#1050). - Claude Code's sign-in (`login`) is magpie's key (`""`, the default) or claude.ai (`claudeai`). claude.ai writes `ANTHROPIC_AUTH_TOKEN` empty, which Claude Code reads as no key: it keeps its claude.ai login and sends that to magpie, which never passes it on (`claudeSignIn` in the gateway). Every later write keeps the token empty (`wiredKey`), and a key put there by hand is reported by Check, not taken for magpie's. It's offered only where the gateway takes any key (`gwKey() == gateway.Token`: loopback, a WSL distro mirrored), not from a distro under NAT while magpie is shared on the network, where only the sharing key gets in. The choice is stashed, so it survives Disconnect and a port move. An empty token counts as magpie's only beside the address magpie last wrote (`claude.wired_at` in the stash, which Rewire carries to the new port, `keyOurs`): an endpoint of the user's own with an empty token stays theirs when the model goes back to Claude Code's own. - MiniMax Code takes a model ref with one slash in the whole (a SubAgent's `model` must match `^[^/\s]+\/[^/\s]+$`, #1387), and magpie's refs there are `custom_provider:magpie/`. So a model under `custom_provider.magpie` is written with `~` for each slash of its catalog id (`bb-codex~gpt-6.1-sol`, `provider.FlatID`), only when that spelling stands for one served id (`provider.Flattened`); the gateway takes it back. A config an older magpie wrote moves over on the next sync: the user's keys on a model go to its new key, `defaultModel`, `defaultLightModel` and `memory.captureModel`/`consolidationModel` follow (`mcodeRefs`), and the slashed key stays, switched off (`enabled: false`), so a session begun on it still resumes. magpie never edits the user's own `agents//agent.md`. No other agent was found with this limit ([`minimax.go`](../../internal/agent/minimax.go)). +- Codex's "subagent model" field writes nothing to Codex's config: its lead names each subagent's model in `spawn_agent`, which `[agents]` can't overrule. It is magpie's setting (`settings.CodexSubagentModel`, [`codex_subagent.go`](../../internal/provider/codex_subagent.go)), and the gateway moves the subagent's request onto it (see [gateway routing](gateway-routing.md)). Its options are only ChatGPT accounts' models, as a subagent's task is sealed for them. Its setter keeps a ChatGPT account's model no account serves now, so a profile or backup put back on another computer still applies (`ApplyFields` stops at the first failed Set), and refuses any other provider's model. - A value an agent spells as its own provider's model is never taken for one of magpie's, even when magpie has a provider of the same name (`Spelled`, #835). - `unreachable` never sets anything: the config is right, and the page's pill only says what has to listen. The exception is an address a WSL distro reached Windows at before (`distro.Was`, kept in `wsl.json`) while it reaches Windows at another now: the pill offers that one, and `Reapply` moves the config there (`Agent.move`, Codex's `codexMoveGateway`). An address no distro was seen at is the user's own and is never moved. An unknown answer (no probe yet) and a gateway down on loopback too are not drift. Tests stub `reachProbe` in `TestMain`. - Listing never starts a stopped WSL distro. A stopped distro's agent shows what was last seen there (`asleep`, `wsl.json`); setting one of its fields writes its files, which starts the distro. diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index 71433490..4951fd29 100644 --- a/docs/subsystems/gateway-routing.md +++ b/docs/subsystems/gateway-routing.md @@ -34,6 +34,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). - `DecidesModel` gives a 400. - A gateway key held to some models (`keyHolds`, #882) gets a 403 for any other model. - Sealed tasks (#619) are checked here. A Codex subagent's task its lead's `spawn_agent` sealed goes only to a ChatGPT account, or back to the Responses provider that answered the lead (`leadProvider`, a relay of the ChatGPT backend such as Sub2API, #1109), relayed as it came even when web search is offered. A lead's turn carrying its subagent's sealed reply (`send_message`) goes back to the Responses provider that answered the lead's own conversation (`leadProvider` of the request's `conversationID`, #1237); otherwise it is refused before anyone is asked (`sealedReader`, `sealedTaskError`). Who answered is first what affinity remembers; when affinity has let it go (a day since, a restart past the 512 conversations `affinity.json` keeps, or the lead's last reply broken off, `unanswered`), the usage log's answers to the lead's thread and the request's own conversation in the last 30 days count too (`loggedAnswers`, #1367), and `leadFirst` reads the lead's key or account from them. A refused or failed call and a title answer nothing, and Codex's own sign-in is told of as the ChatGPT backend. The refusal names who sealed the task when it is known, and says how to go on with it. + - A model set for Codex's subagents (`settings.CodexSubagentModel`, the Agents page's Codex "subagent model", willz on Discord) is put on a Codex subagent's request (`x-openai-subagent` read as `kind:collab_spawn`) before this check, whatever model its lead's `spawn_agent` asked for (`codexSubagentPick` in [`codex_subagent.go`](../../internal/gateway/codex_subagent.go)): in `codexBackend` for one of Codex's own bare models, which then goes through `serveAgent`, and in `serve` for any other. Only a ChatGPT account's model can be set (`provider.SetCodexSubagentModel`), as nothing else reads the sealed task. When no ChatGPT account on in magpie serves it now, or a gateway key held to some models may not use it, the subagent goes on the model asked for. The route trace keeps both models and the reason it stayed (`Route.Subagent`), and the Routing page's `kindWhy` says it. The lead's turns, titles and every other kind are never moved. - Rules and image inputs are handled here too. 3. Candidates come from `planGroup` for a group or `plan` for a provider. They are filtered by `sealedReaders` and `allowedCandidates`. An account past its share is held out of the candidates (`capHeld`): a usage cap's share, each window at its own where the account gives it one (`AccountWindowCaps`, willz on Discord: 50% of the five hours, 40% of the week) and at the account's cap otherwise, or 100% of a window for a Codex account set not to spend its credits (`provider.HoldCaps`, `WindowCaps.Of`, `CodexNoCredits`). The account is held while any window is past its own share, and `cappedError` and the trace name the fullest such window's share and cap. When every candidate is held and one held for its credits spends its resets by itself, `autoReset` spends a reset on it and it is tried (`planned.held`); otherwise a capped or barred set returns `cappedError` or `barredError`. Then `affine` moves the conversation's last answerer first, unless it is resting or nearly used up. An `X-Magpie-Account` header (`AccountHeader`) pins the request to one account: only that account is tried, and when it can't answer the caller is told why. 4. The candidate loop calls `attempt` on each candidate in turn, through a `holdWriter`. The writer holds the reply until it is clearly passing. A candidate that fails before anything is sent rests (`failure`): out of credit for 30 minutes, out of quota until reset, rate limited until retry-after, otherwise a minute and longer on repeats. A 400, 403, 404 or 422 that says the model isn't served (`unservedWords`) and says nothing of quota, credit or a rate limit rests only that key's or account's model (`modelRefused`, keyed by `restID`, its backoff counted apart): its other models are still served first in order (#1235, SenseNova's 403 "model is not available in the current token plan"). A subscription account that fails any other way with a window full, by magpie's latest reading of its windows, rests until that window renews. Both quota rests and window rests are held to `longestQuota` (8 days), so a reset time read wrong (a year off) doesn't bench the account for that long. The next candidate is then tried. A failed provider waits `fallbackCooldown` (1 minute) at the back. The vendor's safety filter refusing a reply rests nobody, whether it refuses the turn before any of the reply is said or once some of it has been said (`refused`, `refusedAfter`). The ChatGPT backend's 502 "response protection is unavailable" is about the request's content, not the account: the same history fails the same on each of its accounts and models. It rests nobody, isn't tried again after a moment, and isn't asked of the provider's other accounts or models; another provider left in a group is asked once (`protectionRefused`, `elsewhere`). One cause is known (#1270): a history that replays the backend's own `web_search_call` in a request that doesn't declare the `web_search` tool is refused so, whatever the account, model or turn (a plain turn, a thread's description, a compaction, Codex's own text compaction for a custom provider with `tools: []`). Every request to the ChatGPT backend with such a history therefore declares cached-only search (`{"type":"web_search","external_web_access":false}`, `provider.DeclareSearch`): an account's in `codexBody`, Codex's own sign-in relayed in `codexUpstreamOn`. It goes in the tools, or for Responses Lite (its header, `reasoning.context` `all_turns`, or an `additional_tools` item already there), which refuses a top-level `web_search`, in the `additional_tools` item. A request that offered no tools gets `tool_choice` `none` so the model can't search; one with a forced choice keeps it. A history without a search, or a request that declares search already, is relayed unchanged. siwc, API-key requests to openai.com and the legacy `/responses/compact` are left as they were. diff --git a/internal/agent/codex.go b/internal/agent/codex.go index 7dafd516..a488e7b2 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -1001,6 +1001,28 @@ func codexIn(at place) *Agent { }, Options: func(map[string]string) []Option { return modelOptions(routed()) }, }, + { + // the model magpie puts every subagent on, whatever the + // lead asked for in spawn_agent (willz on Discord): kept in + // magpie's settings, not Codex's config, since the gateway + // rewrites the subagent's request. Only a ChatGPT account's + // models are offered: a subagent's task is sealed for them + Key: "subagent_model", Label: "subagent model", Quiet: true, + Get: provider.CodexSubagentModel, + Set: provider.SetCodexSubagentModel, + Options: func(map[string]string) []Option { + var out []Option + for _, e := range provider.CodexSubagentModels() { + note := e.Provider.Name + " · via magpie" + if a := e.Provider.Account; a != nil && a.User != "" { + note = a.User + " · via magpie" + } + out = append(out, Option{Value: e.ID, Label: e.Name, Note: note, Icon: e.Provider.Icon, + Group: e.Provider.Name, Ref: e.ID, Context: e.Context}) + } + return out + }, + }, { // [agents] default_subagent_reasoning_effort: unset, a // subagent runs at the session's effort, or at its model's diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index 73eadaaa..bc2b6a9a 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -92,6 +92,17 @@ func (s *Server) codexBackend(w http.ResponseWriter, r *http.Request) { s.codexTitle(w, r, body, to) return } + // a subagent on one of Codex's own models, put on the model + // set for Codex's subagents, a ChatGPT account's in magpie; + // the request on the model asked for when it can't be, the + // trace saying why (codexUpstreamOn, serve) + if !strings.Contains(model, "/") { + pick := codexSubagentPick(r, agentOf(r), requestCallKind(r.Header, requestSessionMetadata(r.Header, body)), model) + r = withSubagentPick(r, pick) + if pick.Moved() { + body, model = withModel(body, pick.To), pick.To + } + } } // The namespace owns the route even if a model is not in the catalog. // Unknown providers/groups must fail locally, never fall through to OpenAI. @@ -540,7 +551,7 @@ func (s *Server) codexUpstreamOn(w http.ResponseWriter, r *http.Request, rest st seat := Weighed{ID: "codex", Provider: "openai", Name: "OpenAI", Icon: "openai", Who: who, Kind: "account", Agent: "codex", Model: model} link := s.titlePrompts.observe(r, body, metadata, kind, start) captureTitle = link != nil && isTitleKind(kind) - tr = s.trace.begin(Route{imageTurn: drawingTurnID(metadata.Turn), imageCaller: codexTurnKey(r, callerOf(r).agent), imageProvider: imageProvider, TitleLink: link, Time: start, Agent: agentOf(r), Session: sessionOf(r.Header), Conv: convOf(r.Header, body), ParentSession: titleParentSession(r.Header, metadata, kind), Kind: kind, Model: model, Effort: effort, Provider: "openai", + tr = s.trace.begin(Route{imageTurn: drawingTurnID(metadata.Turn), imageCaller: codexTurnKey(r, callerOf(r).agent), imageProvider: imageProvider, TitleLink: link, Time: start, Agent: agentOf(r), Session: sessionOf(r.Header), Conv: convOf(r.Header, body), ParentSession: titleParentSession(r.Header, metadata, kind), Kind: kind, Model: model, Effort: effort, Provider: "openai", Subagent: subagentPickOf(r), Order: []Weighed{seat}, Tries: []Try{{ID: seat.ID, Model: model, Effort: effort, Start: start}}}) promptRead := s.inspectPrompt(tr, provider.Responses, body) end = func(status int, msg string, tokens, out int) { diff --git a/internal/gateway/codex_subagent.go b/internal/gateway/codex_subagent.go new file mode 100644 index 00000000..a5380e74 --- /dev/null +++ b/internal/gateway/codex_subagent.go @@ -0,0 +1,65 @@ +package gateway + +import ( + "context" + "net/http" + + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/usage" +) + +// SubagentPick is a Codex subagent's request put on the model the user +// set for Codex's subagents (provider.CodexSubagentModel), in place of +// the one its lead asked for in spawn_agent (willz on Discord). Kept says +// why it went on the model asked for instead, "" when it was put on To. +type SubagentPick struct { + Asked string `json:"asked"` + To string `json:"to"` + Kept string `json:"kept,omitempty"` +} + +// Moved says the request went on To. +func (p *SubagentPick) Moved() bool { return p != nil && p.Kept == "" } + +type subagentPickKey struct{} + +func withSubagentPick(r *http.Request, p *SubagentPick) *http.Request { + if p == nil { + return r + } + return r.WithContext(context.WithValue(r.Context(), subagentPickKey{}, p)) +} + +func subagentPickOf(r *http.Request) *SubagentPick { + p, _ := r.Context().Value(subagentPickKey{}).(*SubagentPick) + return p +} + +// codexSubagentPick is what is done with the model of a request: nil when +// it isn't a Codex subagent's or no model is set for them, so the request +// goes on as asked. The model set must still be served by a ChatGPT +// account (a subagent's task is sealed for them) and be one the gateway +// key may use; when it isn't, the request goes on the model asked for and +// the pick says why. +func codexSubagentPick(r *http.Request, agent, kind, asked string) *SubagentPick { + // Codex by its token or by its User-Agent (fromCodex: its CLI, exec, + // the IDE extension, the desktop app) + if agent != "codex" && !fromCodex(r.Header) || usage.PurposeOf(kind) != "kind:collab_spawn" { + return nil + } + to := provider.CodexSubagentModel() + if to == "" || to == asked { + return nil + } + pick := &SubagentPick{Asked: asked, To: to} + if why := provider.CodexSubagentUnserved(to); why != "" { + pick.Kept = why + return pick + } + if who, held := keyHolds(r); held { + if p, model, ok := provider.Resolve(to); !ok || !modelAllowed(who, p, model) { + pick.Kept = "the gateway key may not use it" + } + } + return pick +} diff --git a/internal/gateway/codex_subagent_test.go b/internal/gateway/codex_subagent_test.go new file mode 100644 index 00000000..90e63531 --- /dev/null +++ b/internal/gateway/codex_subagent_test.go @@ -0,0 +1,222 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/provider" +) + +// chatgptSeen stands in for the ChatGPT backend, noting each request's +// account and model. +type chatgptSeen struct { + mu sync.Mutex + models []string +} + +func (c *chatgptSeen) asked() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.models...) +} + +// codexSubagentSetup is Codex signed in to ChatGPT with another account on +// in magpie, both serving gpt-5.5 and gpt-6-astra, and the ChatGPT backend +// a fake noting what it was asked. +func codexSubagentSetup(t *testing.T) *chatgptSeen { + t.Helper() + codexSignedIn(t, "spare@example.com") + home, _ := os.UserHomeDir() + os.WriteFile(filepath.Join(home, ".codex", "models_cache.json"), []byte(`{"etag":"\"v1\"","models":[ + {"slug":"gpt-5.5","display_name":"GPT-5.5","visibility":"list","priority":1,"context_window":272000}, + {"slug":"gpt-6-astra","display_name":"GPT-6 Astra","visibility":"list","priority":2,"context_window":272000}]}`), 0o644) + provider.ForgetAccounts() + c := &chatgptSeen{} + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + if !strings.HasSuffix(r.URL.Path, "/responses") { + w.WriteHeader(404) + return + } + c.mu.Lock() + c.models = append(c.models, modelOf(b)) + c.mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse( + `data: {"type":"response.created","response":{"id":"r1","model":"`+modelOf(b)+`"}}`, + `data: {"type":"response.output_text.delta","delta":"on it"}`, + `data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":7,"output_tokens":1}}}`)) + })) + t.Cleanup(up.Close) + was := provider.CodexBase + provider.CodexBase = up.URL + "/backend-api/codex" + t.Cleanup(func() { provider.CodexBase = was }) + return c +} + +// codexAsks is a request of Codex's, a subagent's when kind is +// collab_spawn, a lead's when it is "". +func codexAsks(s *Server, model, kind, input string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + body := `{"model":"` + model + `","stream":true,"input":[` + input + `]}` + req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer chatgpt-token") + req.Header.Set("chatgpt-account-id", "acct-1") + req.Header.Set("User-Agent", "codex_cli_rs/0.159.0 (Mac OS 26.2.0; arm64)") + req.Header.Set("session_id", "worker-1") + if kind != "" { + req.Header.Set("x-openai-subagent", kind) + req.Header.Set("x-codex-parent-thread-id", "lead-1") + } + s.Handler().ServeHTTP(rec, req) + return rec +} + +// willz on Discord: Codex's lead puts each subagent on a model of its own +// pick in spawn_agent, and Codex's config can't overrule it. With a model +// set for Codex's subagents, a subagent's sealed task on one of Codex's +// own models goes to a ChatGPT account on that model instead, and the +// trace says what it was asked for and what it went on. +func TestCodexSubagentPutOnTheModelSet(t *testing.T) { + up := codexSubagentSetup(t) + if err := provider.SetCodexSubagentModel("codex/gpt-6-astra"); err != nil { + t.Fatal(err) + } + s := New() + rec := codexAsks(s, "gpt-5.5", "collab_spawn", sealedTaskFernet) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "on it") { + t.Fatalf("subagent: %d %s", rec.Code, rec.Body.String()) + } + if got := up.asked(); len(got) != 1 || got[0] != "gpt-6-astra" { + t.Fatalf("ChatGPT was asked for %v, not gpt-6-astra", got) + } + r := lastRoute(s) + if r.Subagent == nil || *r.Subagent != (SubagentPick{Asked: "gpt-5.5", To: "codex/gpt-6-astra"}) || r.Model != "gpt-5.5" || r.Kind != "collab_spawn" { + t.Fatalf("trace: model %q kind %q subagent %+v", r.Model, r.Kind, r.Subagent) + } +} + +// A subagent the lead put on another provider's model (a relay's) — its +// task sealed for ChatGPT accounts, so refused there before anyone is +// asked — goes on the model set for Codex's subagents, a ChatGPT +// account's, and the relay isn't asked. +func TestCodexSubagentOnAnotherProviderPutOnTheModelSet(t *testing.T) { + up := codexSubagentSetup(t) + relay := relayOn(t, "third") + if err := provider.SetCodexSubagentModel("codex/gpt-6-astra"); err != nil { + t.Fatal(err) + } + s := New() + rec := codexAsks(s, "third/gpt-6-astra", "collab_spawn", sealedTaskFernet) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "on it") { + t.Fatalf("subagent: %d %s", rec.Code, rec.Body.String()) + } + if got := up.asked(); len(got) != 1 || got[0] != "gpt-6-astra" { + t.Fatalf("ChatGPT was asked for %v", got) + } + if n := len(relay.requests()); n != 0 { + t.Fatalf("the relay was asked %d times", n) + } + r := lastRoute(s) + if r.Subagent == nil || *r.Subagent != (SubagentPick{Asked: "third/gpt-6-astra", To: "codex/gpt-6-astra"}) || r.Model != "third/gpt-6-astra" { + t.Fatalf("trace: model %q subagent %+v", r.Model, r.Subagent) + } +} + +// The lead's own turns, and every request with no model set for the +// subagents, go on the model asked for, the trace saying nothing of it. +func TestCodexSubagentModelLeavesOtherRequests(t *testing.T) { + up := codexSubagentSetup(t) + s := New() + // none set: a subagent as asked + if rec := codexAsks(s, "gpt-5.5", "collab_spawn", sealedTaskFernet); rec.Code != 200 { + t.Fatalf("subagent: %d %s", rec.Code, rec.Body.String()) + } + if r := lastRoute(s); r.Subagent != nil { + t.Fatalf("none set, traced %+v", r.Subagent) + } + if err := provider.SetCodexSubagentModel("codex/gpt-6-astra"); err != nil { + t.Fatal(err) + } + // set: the lead's turn and a title as asked + for _, kind := range []string{"", "thread_title", "guardian"} { + if rec := codexAsks(s, "gpt-5.5", kind, leadAsk); rec.Code != 200 { + t.Fatalf("%q: %d %s", kind, rec.Code, rec.Body.String()) + } + if r := lastRoute(s); r.Subagent != nil { + t.Fatalf("%q traced %+v", kind, r.Subagent) + } + } + got := up.asked() + for i, m := range got { + if m != "gpt-5.5" { + t.Fatalf("request %d went on %s: %v", i, m, got) + } + } +} + +// The model set no longer served by a ChatGPT account in magpie (its +// account signed out, or the model gone from its plan): a subagent goes on +// the model its lead asked for, and the trace says why it wasn't put on +// the one set. Another provider's model can't be set at all. +func TestCodexSubagentModelNotServedKeepsTheAsked(t *testing.T) { + up := codexSubagentSetup(t) + relayOn(t, "third") + if err := provider.SetCodexSubagentModel("third/gpt-6-astra"); err == nil || !strings.Contains(err.Error(), "sealed for ChatGPT accounts") { + t.Fatalf("set another provider's model: %v", err) + } + // kept as set, as a backup put back on a computer whose accounts + // differ sets it + if err := provider.SetCodexSubagentModel("codex/gpt-9"); err != nil { + t.Fatal(err) + } + s := New() + rec := codexAsks(s, "gpt-5.5", "collab_spawn", sealedTaskFernet) + if rec.Code != 200 { + t.Fatalf("subagent: %d %s", rec.Code, rec.Body.String()) + } + if got := up.asked(); len(got) != 1 || got[0] != "gpt-5.5" { + t.Fatalf("ChatGPT was asked for %v, not the model asked", got) + } + r := lastRoute(s) + if r.Subagent == nil || r.Subagent.To != "codex/gpt-9" || r.Subagent.Kept != "no ChatGPT account on in magpie serves it" { + t.Fatalf("trace: %+v", r.Subagent) + } +} + +// A gateway key held to some models (#882) that doesn't name the model set +// for Codex's subagents: a subagent on it goes on the model asked for, as +// the key may use that one, and the trace says why. +func TestCodexSubagentModelHeldKeyKeepsTheAsked(t *testing.T) { + up := codexSubagentSetup(t) + if err := provider.SetCodexSubagentModel("codex/gpt-6-astra"); err != nil { + t.Fatal(err) + } + s := New() + keys, secrets := newCaller(t, "Held") + if _, err := access.Update("models-key", access.Change{Key: keys[0].ID, Models: []string{"codex/gpt-5.5"}}); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", CodexPath+"/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":[`+sealedTaskFernet+`]}`)) + req.Header.Set("Authorization", "Bearer "+secrets[0]) + req.Header.Set("User-Agent", "codex_cli_rs/0.159.0 (Mac OS 26.2.0; arm64)") + req.Header.Set("x-openai-subagent", "collab_spawn") + s.Handler().ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("subagent: %d %s", rec.Code, rec.Body.String()) + } + if got := up.asked(); len(got) != 1 || got[0] != "gpt-5.5" { + t.Fatalf("ChatGPT was asked for %v, not the model asked", got) + } + if r := lastRoute(s); r.Subagent == nil || r.Subagent.Kept != "the gateway key may not use it" { + t.Fatalf("trace: %+v", r.Subagent) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index cb1b8132..61c2d59d 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1313,6 +1313,16 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro if m := s.codexMemoryStandIn(r, call.Agent, agent, call.Kind, asked); m != "" { asked = m } + // a Codex subagent on the model set for Codex's subagents, whatever + // its lead asked for (willz on Discord); one already put on it by the + // ChatGPT backend's route is recorded as asked for the model it was + subagent := subagentPickOf(r) + if subagent == nil { + subagent = codexSubagentPick(r, agent, call.Kind, call.Model) + } + if subagent.Moved() { + call.Model, asked = subagent.Asked, subagent.To + } p, model, ok := provider.Resolve(asked) if ok { s.rememberCodexTurn(r, call.Agent, agent, call.Kind, asked) @@ -1705,7 +1715,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro shown = nil // nobody else to stay away from } link := s.titlePrompts.observe(r, body, metadata, call.Kind, start) - tr := s.trace.begin(Route{imageTurn: drawingTurnID(metadata.Turn), imageCaller: codexTurnKey(r, call.Agent), TitleLink: link, Pinned: pin, Time: start, Agent: call.Agent, Session: sessionOf(r.Header), Conv: convOf(r.Header, body), ParentSession: titleParentSession(r.Header, metadata, call.Kind), Kind: call.Kind, For: call.For, Model: call.Model, Effort: requestEffort(from, body), Provider: p.ID, Group: group, Rule: hit, Nested: nested, SealedTask: sealedTask, LeadAccount: leadAccount, Affinity: shown, Order: pl.order, Left: pl.left}) + tr := s.trace.begin(Route{imageTurn: drawingTurnID(metadata.Turn), imageCaller: codexTurnKey(r, call.Agent), TitleLink: link, Pinned: pin, Time: start, Agent: call.Agent, Session: sessionOf(r.Header), Conv: convOf(r.Header, body), ParentSession: titleParentSession(r.Header, metadata, call.Kind), Kind: call.Kind, For: call.For, Model: call.Model, Effort: requestEffort(from, body), Provider: p.ID, Group: group, Rule: hit, Nested: nested, SealedTask: sealedTask, LeadAccount: leadAccount, Subagent: subagent, Affinity: shown, Order: pl.order, Left: pl.left}) if telemetry != nil { telemetry.routeID = tr.ID } diff --git a/internal/gateway/trace.go b/internal/gateway/trace.go index c259e946..2a3a8084 100644 --- a/internal/gateway/trace.go +++ b/internal/gateway/trace.go @@ -45,6 +45,9 @@ type Route struct { Rule *RuleHit `json:"rule,omitempty"` // the group's rules for it, when it has any SealedTask bool `json:"sealedTask,omitempty"` // only ChatGPT accounts can read this subagent's task LeadAccount string `json:"leadAccount,omitempty"` // the parent account put first for a sealed task + // Subagent is a Codex subagent's request put on the model set for + // Codex's subagents, or kept on the one asked for and why + Subagent *SubagentPick `json:"subagent,omitempty"` // Nested: the rules of the groups in the group, down the way to the // one that went first, each as it decided Nested []NestedRule `json:"nested,omitempty"` diff --git a/internal/gui/api.go b/internal/gui/api.go index b991c697..2b633561 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -1032,6 +1032,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { in.CodexTitles = cur.CodexTitles // set on its own (codex-titles below) // and so is the model Codex's auto-review runs on (codex-auto-review) in.CodexAutoReview = cur.CodexAutoReview + // and the model Codex's subagents are put on, set in Codex's row + in.CodexSubagentModel = cur.CodexSubagentModel in.ChinaMirror = cur.ChinaMirror // the Plugins page's, set on its own // which Codex accounts spend a reset by themselves, set on the Usage card in.CodexAutoReset = cur.CodexAutoReset diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 3ab6abd7..37a0cca4 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -296,7 +296,7 @@ function renderAgents() { // 4.5, ultracode short of xhigh) isn't drawn at all, nor are subagents // with no model to go on, or a sign-in with no other to take (Claude // Code's, until it runs through magpie where magpie takes any key) - const none = (f) => (f.key === "effort" || f.key === "ultracode" || f.label === "subagents" || f.label === SUB_EFFORT || f.label === MEMORIES || f.label === "sign-in") && !f.options.length && !f.value; + const none = (f) => (f.key === "effort" || f.key === "ultracode" || f.label === "subagents" || f.label === SUB_EFFORT || f.label === SUB_MODEL || f.label === MEMORIES || f.label === "sign-in") && !f.options.length && !f.value; const shownFields = a.fields.filter((f) => !TIERS.includes(f.label) && !TIER_EFFORTS.includes(f.label) && !none(f)); const tiers = tierMenu(a); if (tiers) shownFields.push(tiers); @@ -2915,7 +2915,11 @@ const PICKS_ITSELF = ["pi", "omo"]; // its memories with (Yc on Discord). const SUB_EFFORT = "subagent effort"; const MEMORIES = "memories"; -const extra = (f) => f.key === "tiers" || FOLLOWS_MODEL.includes(f.label) || f.label === "sign-in" || f.key === "ultracode" || f.label === SUB_EFFORT || f.label === MEMORIES; +// the model magpie puts every Codex subagent on, whatever its lead asked +// for (willz on Discord): one of a ChatGPT account's, as a subagent's task +// is sealed for them +const SUB_MODEL = "subagent model"; +const extra = (f) => f.key === "tiers" || FOLLOWS_MODEL.includes(f.label) || f.label === "sign-in" || f.key === "ultracode" || f.label === SUB_EFFORT || f.label === SUB_MODEL || f.label === MEMORIES; const EXTRA_GLYPH = { subagents: "M4.5 2.75v10.5M4.5 9.25c0-2.2 1.6-3.75 3.9-3.75h3.35M9.9 3.6l1.9 1.9-1.9 1.9", // a feather for omp's smol role, an hourglass for its slow one @@ -2926,6 +2930,8 @@ const EXTRA_GLYPH = { ultracode: "M3 4.25h4M3 8h2.5M3 11.75h4M9.5 4.25l3.5 3.75-3.5 3.75", // the subagents' branch, with effort's rising bars after it [SUB_EFFORT]: "M3.25 2.75v10.5M3.25 9.25c0-2.2 1.6-3.75 3.9-3.75h.6M9.25 13.25v-2M11.5 13.25v-4M13.75 13.25v-6", + // the subagents' branch, ending on a pin: their model, set by magpie + [SUB_MODEL]: "M4 2.75v10.5M4 9.25c0-2.2 1.6-3.75 3.9-3.75h1.1M11.75 3.25a2.25 2.25 0 1 1 0 4.5 2.25 2.25 0 0 1 0-4.5zM11.75 7.75v4", // a notebook: Codex's memories [MEMORIES]: "M4.25 2.75h7.5v10.5h-7.5zM6.25 2.75v10.5M8.25 5.75h1.75M8.25 8h1.75", }; @@ -2939,7 +2945,7 @@ function extraField(a, f) { b.append(svg(EXTRA_GLYPH[f.label] || EXTRA_GLYPH.tiers, 13, 1.5)); const opt = optionFor(f, f.value); b.title = f.label === SUB_EFFORT ? subEffortTitle(a, f, opt) : f.menu ? menuTitle(f) - : t("{label}: {value}", { label: f.label === MEMORIES ? t("Codex memories model") : t(f.label), value: t(opt?.label || f.value || (f.label === MEMORIES ? MEMORIES_DEFAULT : "same as model")) }) + (opt?.note && !FOLLOWS_MODEL.includes(f.label) ? "\n" + t(opt.note) : ""); + : t("{label}: {value}", { label: f.label === MEMORIES ? t("Codex memories model") : t(f.label), value: t(opt?.label || f.value || (f.label === MEMORIES ? MEMORIES_DEFAULT : f.label === SUB_MODEL ? "the model the lead asks for" : "same as model")) }) + (opt?.note && !FOLLOWS_MODEL.includes(f.label) ? "\n" + t(opt.note) : ""); b.setAttribute("aria-label", b.title); b.dataset.key = f.key; b.onclick = (ev) => openPicker(a, f, b, ev); @@ -3700,7 +3706,9 @@ function openPicker(agent, field, anchor, ev, only) { // saved there with Ctrl+S (#709) const note = agent.id === "reasonix" && ["model", "planner"].includes(field.key) ? "restore the previous {field} selection" : PICKS_ITSELF.includes(agent.id) && field.key === "model" ? "clears the default model; {agent} picks one on its own" - : field.label === MEMORIES ? MEMORIES_DEFAULT : "what {agent} ships with"; + : field.label === MEMORIES ? MEMORIES_DEFAULT + : field.label === SUB_MODEL ? "each subagent on the model {agent}'s lead asks for" + : "what {agent} ships with"; options.unshift({ value: "", label: t("Default"), note: t(note, { agent: agent.name, field: t(field.label) }), icon: agent.icon, reset: true }); } // Default is the agent as installed; this is the agent as it was before @@ -3709,7 +3717,7 @@ function openPicker(agent, field, anchor, ev, only) { const at = options.findIndex((o) => !o.reset); options.splice(at < 0 ? options.length : at, 0, { value: "\0disconnect", label: t("Disconnect from magpie"), note: t("put back what {agent} had before magpie", { agent: agent.name }), svg: UNPLUG, reset: true, run: () => askDisconnect(agent) }); } - const modelPicker = ["model", "small", "large", MEMORIES, "executor", "planner", ...FOLLOWS_MODEL].includes(field.label) && !only; + const modelPicker = ["model", "small", "large", MEMORIES, SUB_MODEL, "executor", "planner", ...FOLLOWS_MODEL].includes(field.label) && !only; pick = { agent, field, options, groups, anchor, cursor: 0, free: !only && !field.menu, modelPicker, effortPicker, groupFilter: "all" }; anchor.classList.add("open"); const pop = $("#pop"); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index adc9a40d..7e679ba9 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -3065,6 +3065,14 @@ const I18N = { "{who} goes first: it answered the parent agent and may be needed to read this encrypted task.": "{who} 回答了主代理,因此优先用它读取这条加密任务。", "{agent} requested a subagent; magpie selects its model within this routing group.": "{agent} 请求了子代理,由 magpie 在此路由组内选择模型。", "{agent} requested a subagent on {model}.": "{agent} 请求了使用 {model} 的子代理。", + "subagent model": "子代理模型", + "the model the lead asks for": "主代理请求的模型", + "each subagent on the model {agent}'s lead asks for": "每个子代理都用 {agent} 主代理请求的模型", + "{agent} requested a subagent on {model} and it stayed there: its subagents are set to {to}, but {why}.": "{agent} 请求了使用 {model} 的子代理,并保留在该模型上:它的子代理设为 {to},但{why}。", + "{agent} requested a subagent on {model}; magpie put it on {to}, the model set for its subagents.": "{agent} 请求了使用 {model} 的子代理;magpie 将其改用 {to},即为它的子代理设定的模型。", + "no ChatGPT account is on in magpie": "magpie 中没有启用的 ChatGPT 账号", + "no ChatGPT account on in magpie serves it": "magpie 中启用的 ChatGPT 账号都不提供该模型", + "the gateway key may not use it": "网关密钥不允许使用该模型", "Luna Reserve": "Luna 储备", "Suggestions": "提示词建议", "Background prompt suggestions": "后台提示词建议", @@ -6919,6 +6927,14 @@ const I18N = { "{who} goes first: it answered the parent agent and may be needed to read this encrypted task.": "{who} 回答了主代理,因此優先用它讀取這條加密任務。", "{agent} requested a subagent; magpie selects its model within this routing group.": "{agent} 請求了子代理,由 magpie 在此路由組內選擇模型。", "{agent} requested a subagent on {model}.": "{agent} 請求了使用 {model} 的子代理。", + "subagent model": "子代理模型", + "the model the lead asks for": "主代理請求的模型", + "each subagent on the model {agent}'s lead asks for": "每個子代理都用 {agent} 主代理請求的模型", + "{agent} requested a subagent on {model} and it stayed there: its subagents are set to {to}, but {why}.": "{agent} 請求了使用 {model} 的子代理,並保留在該模型上:它的子代理設為 {to},但{why}。", + "{agent} requested a subagent on {model}; magpie put it on {to}, the model set for its subagents.": "{agent} 請求了使用 {model} 的子代理;magpie 將其改用 {to},即為它的子代理設定的模型。", + "no ChatGPT account is on in magpie": "magpie 中沒有啟用的 ChatGPT 帳號", + "no ChatGPT account on in magpie serves it": "magpie 中啟用的 ChatGPT 帳號都不提供該模型", + "the gateway key may not use it": "閘道金鑰不允許使用該模型", "Luna Reserve": "Luna 儲備", "Suggestions": "提示詞建議", "Background prompt suggestions": "後臺提示詞建議", @@ -10750,6 +10766,14 @@ const I18N = { "{who} goes first: it answered the parent agent and may be needed to read this encrypted task.": "{who} が先です:親エージェントに応答しており、この暗号化されたタスクを読むのに必要な可能性があります。", "{agent} requested a subagent; magpie selects its model within this routing group.": "{agent} がサブエージェントをリクエストしました。モデルは magpie がこのルーティンググループ内で選びます。", "{agent} requested a subagent on {model}.": "{agent} が {model} のサブエージェントをリクエストしました。", + "subagent model": "サブエージェントのモデル", + "the model the lead asks for": "リードがリクエストしたモデル", + "each subagent on the model {agent}'s lead asks for": "各サブエージェントは {agent} のリードがリクエストしたモデルを使います", + "{agent} requested a subagent on {model} and it stayed there: its subagents are set to {to}, but {why}.": "{agent} が {model} のサブエージェントをリクエストし、そのモデルのままになりました。サブエージェントは {to} に設定されていますが、{why}。", + "{agent} requested a subagent on {model}; magpie put it on {to}, the model set for its subagents.": "{agent} が {model} のサブエージェントをリクエストしました。magpie はサブエージェント用に設定された {to} に切り替えました。", + "no ChatGPT account is on in magpie": "magpie で有効な ChatGPT アカウントがありません", + "no ChatGPT account on in magpie serves it": "magpie で有効な ChatGPT アカウントはどれもこのモデルを提供していません", + "the gateway key may not use it": "ゲートウェイキーではこのモデルを使えません", "Luna Reserve": "Luna Reserve", "Suggestions": "提案", "Background prompt suggestions": "バックグラウンドのプロンプト候補", @@ -14598,6 +14622,14 @@ const I18N = { "{who} goes first: it answered the parent agent and may be needed to read this encrypted task.": "{who} kommt zuerst: Es hat dem übergeordneten Agenten geantwortet und wird eventuell zum Lesen dieser verschlüsselten Aufgabe gebraucht.", "{agent} requested a subagent; magpie selects its model within this routing group.": "{agent} hat einen Subagenten angefordert; magpie wählt sein Modell innerhalb dieser Weiterleitungsgruppe.", "{agent} requested a subagent on {model}.": "{agent} hat einen Subagent mit {model} angefordert.", + "subagent model": "Modell der Subagenten", + "the model the lead asks for": "das Modell, das der Haupt-Agent anfordert", + "each subagent on the model {agent}'s lead asks for": "jeder Subagent auf dem Modell, das der Haupt-Agent von {agent} anfordert", + "{agent} requested a subagent on {model} and it stayed there: its subagents are set to {to}, but {why}.": "{agent} hat einen Subagent mit {model} angefordert, und er blieb dort: Seine Subagenten sind auf {to} gesetzt, aber {why}.", + "{agent} requested a subagent on {model}; magpie put it on {to}, the model set for its subagents.": "{agent} hat einen Subagent mit {model} angefordert; magpie hat ihn auf {to} gesetzt, das für seine Subagenten festgelegte Modell.", + "no ChatGPT account is on in magpie": "in magpie ist kein ChatGPT-Konto eingeschaltet", + "no ChatGPT account on in magpie serves it": "kein eingeschaltetes ChatGPT-Konto in magpie bietet es an", + "the gateway key may not use it": "der Gateway-Schlüssel darf es nicht verwenden", "Luna Reserve": "Luna Reserve", "Suggestions": "Vorschläge", "Background prompt suggestions": "Prompt-Vorschläge im Hintergrund", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 4298e730..1eebf103 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -432,6 +432,10 @@ } } + // the model a request went on: the one asked for, or the one set for + // Codex's subagents that magpie put a subagent on in its place + const wentOn = (r) => (r.subagent && !r.subagent.kept ? r.subagent.to : r.model); + // why routing put the first where it did function firstWhy(r) { const f = r.order[0]; @@ -440,7 +444,7 @@ if (r.sealedTask && r.leadAccount === f.id) return t("{who} goes first: it answered the parent agent and may be needed to read this encrypted task.", { who: w }); if (r.order.length === 1) { if (f.rest) return t("{who} is the only one, so it's tried though it is resting.", { who: w }); - return f.kind === "account" ? t("{who} is the only account on for {model} — nothing to choose between.", { who: w, model: r.model }) + return f.kind === "account" ? t("{who} is the only account on for {model} — nothing to choose between.", { who: w, model: wentOn(r) }) : t("{name} has one key on — nothing to choose between.", { name: f.name }); } if (f.rest) return t("Every one is resting after a failure, so {who}, first in line, is tried all the same.", { who: w }); @@ -1572,7 +1576,7 @@ const main = r.order.find((x) => !x.fallback); items.push([r.group ? t("{agent} asked for the routing group {name}: {members}", { agent: agentName(r.agent), name: r.group.name, members: treeText(r.group) }) - : main && main.model !== r.model + : main && main.model !== r.model && wentOn(r) === r.model ? t("{agent} asked for {model}: {name} serves it, and the vendor is asked for {sent}", { agent: agentName(r.agent), model: r.model, name: main.name, sent: main.model }) : t("{agent} asked for {model}", { agent: agentName(r.agent), model: r.model }) + " → " + (main?.name || r.provider), ""]); if (r.kind) items.push([kindWhy(r), "aside kind"]); @@ -1710,6 +1714,11 @@ const upstreamWhy = (tr) => t("The aggregator passed the request on to {upstream}, as its reply says: the provider that actually answered it.", { upstream: tr.upstream }); function kindWhy(r) { const agent = agentName(r.agent); + // the model set for Codex's subagents in place of the lead's pick, or + // why the lead's pick stood + if (purposeOf(r.kind) === "kind:collab_spawn" && r.subagent) return r.subagent.kept + ? t("{agent} requested a subagent on {model} and it stayed there: its subagents are set to {to}, but {why}.", { agent, model: r.subagent.asked, to: r.subagent.to, why: t(r.subagent.kept) }) + : t("{agent} requested a subagent on {model}; magpie put it on {to}, the model set for its subagents.", { agent, model: r.subagent.asked, to: r.subagent.to }); if (purposeOf(r.kind) === "kind:collab_spawn") return r.group ? t("{agent} requested a subagent; magpie selects its model within this routing group.", { agent }) : t("{agent} requested a subagent on {model}.", { agent, model: r.model }); diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index ca451b53..d526f661 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -144,6 +144,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) { CodexAgentsV1: true, CodexTitles: "off", CodexAutoReview: "fake/m1", + CodexSubagentModel: "codex/gpt-6-astra", ChinaMirror: true, CodexAutoReset: []string{"me@example.com"}, CodexNoCredits: []string{"me@example.com"}, diff --git a/internal/gui/tests/codex-subagent-model.test.cjs b/internal/gui/tests/codex-subagent-model.test.cjs new file mode 100644 index 00000000..5401bcd3 --- /dev/null +++ b/internal/gui/tests/codex-subagent-model.test.cjs @@ -0,0 +1,191 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Codex's lead picks each subagent's model itself, and Codex's own config +// can't overrule it (willz on Discord), so magpie puts every Codex subagent +// on a model set in Codex's row: a square opening the app's model picker +// (no native select) with only a ChatGPT account's models, as a subagent's +// task is sealed for them, Default first. A model picked is posted and +// lights the square; Default posts "". The Routing page's story of a +// subagent's request says it was put on that model, or why it stayed on +// the one asked for. A click scrolls nothing, nothing has a coloured left +// border, and the row fits a narrow window, in every language. No backend: +// the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const own = [{ value: "gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }]; +const chatgpt = [ + { value: "codex/gpt-6-astra", label: "GPT-6 Astra", note: "me@example.com · via magpie", group: "ChatGPT", ref: "codex/gpt-6-astra", icon: "openai" }, + { value: "codex/gpt-5.5", label: "GPT-5.5", note: "me@example.com · via magpie", group: "ChatGPT", ref: "codex/gpt-5.5", icon: "openai" }, +]; +const fresh = () => ({ + agents: [{ + id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true, + fields: [ + { key: "model", label: "model", value: "gpt-5.5", options: own }, + { key: "subagent_model", label: "subagent model", value: "", options: chatgpt }, + ], + }, + // no ChatGPT account in magpie: no square + { + id: "codex2", name: "Codex", path: "/test2/config.toml", icon: "codex-color", wired: false, + fields: [{ key: "model", label: "model", value: "gpt-5.5", options: own }, { key: "subagent_model", label: "subagent model", value: "", options: [] }], + }], + profiles: [], +}); + +const now = new Date(); +const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n) => String(n).padStart(2, "0")).join("-"); +const at = (i) => new Date(now.getTime() - (i + 1) * 60e3).toISOString(); +const seat = { id: "codex", provider: "codex", name: "ChatGPT", who: "me@example.com", kind: "account", agent: "codex", model: "gpt-6-astra" }; +const routes = [ + { id: 2, seq: 2, time: at(0), agent: "codex", kind: "collab_spawn", model: "gpt-5.5", provider: "codex", subagent: { asked: "gpt-5.5", to: "codex/gpt-6-astra" }, + order: [seat], tries: [{ id: "codex", model: "gpt-6-astra", start: at(0), done: true, status: 200, ms: 900 }], done: true, status: 200, ms: 900, tokens: 1200 }, + { id: 1, seq: 1, time: at(1), agent: "codex", kind: "collab_spawn", model: "gpt-5.5", provider: "openai", subagent: { asked: "gpt-5.5", to: "codex/gpt-6-astra", kept: "no ChatGPT account on in magpie serves it" }, + order: [{ ...seat, provider: "openai", model: "gpt-5.5" }], tries: [{ id: "codex", model: "gpt-5.5", start: at(1), done: true, status: 200, ms: 900 }], done: true, status: 200, ms: 900, tokens: 1200 }, +]; + +function server(lang, sets) { + let cur = fresh(); + return async (route) => { + const req = route.request(), url = new URL(req.url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ ...cur, settings: { lang, theme: "light" } }); + if (url.pathname === "/api/set") { + const body = req.postDataJSON(); + sets.push(body); + cur = JSON.parse(JSON.stringify(cur)); + cur.agents.find((a) => a.id === body.agent).fields.find((f) => f.key === body.field).value = body.value; + return json({ ...cur, settings: { lang, theme: "light" } }); + } + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3)); + return json({ mine: true, now: now.toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] }); + } + if (url.pathname === "/api/gateway/history") { + const d = url.searchParams.get("day"); + return json({ cut: false, days: [{ day, requests: routes.length }], routes: d ? routes : [] }); + } + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false }); + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +// the words as i18n.js has them +const W = { + en: { unset: "subagent model: the model the lead asks for", def: "Default", set: "subagent model: GPT-6 Astra", + moved: "Codex requested a subagent on gpt-5.5; magpie put it on codex/gpt-6-astra, the model set for its subagents.", + kept: "Codex requested a subagent on gpt-5.5 and it stayed there: its subagents are set to codex/gpt-6-astra, but no ChatGPT account on in magpie serves it." }, + zh: { unset: "子代理模型:主代理请求的模型", def: "默认", set: "子代理模型:GPT-6 Astra", + moved: "Codex 请求了使用 gpt-5.5 的子代理;magpie 将其改用 codex/gpt-6-astra,即为它的子代理设定的模型。", + kept: "Codex 请求了使用 gpt-5.5 的子代理,并保留在该模型上:它的子代理设为 codex/gpt-6-astra,但magpie 中启用的 ChatGPT 账号都不提供该模型。" }, + "zh-TW": { unset: "子代理模型:主代理請求的模型", def: null, set: "子代理模型:GPT-6 Astra", + moved: "Codex 請求了使用 gpt-5.5 的子代理;magpie 將其改用 codex/gpt-6-astra,即為它的子代理設定的模型。", + kept: "Codex 請求了使用 gpt-5.5 的子代理,並保留在該模型上:它的子代理設為 codex/gpt-6-astra,但magpie 中啟用的 ChatGPT 帳號都不提供該模型。" }, + ja: { unset: "サブエージェントのモデル:リードがリクエストしたモデル", def: null, set: "サブエージェントのモデル:GPT-6 Astra", + moved: "Codex が gpt-5.5 のサブエージェントをリクエストしました。magpie はサブエージェント用に設定された codex/gpt-6-astra に切り替えました。", + kept: "Codex が gpt-5.5 のサブエージェントをリクエストし、そのモデルのままになりました。サブエージェントは codex/gpt-6-astra に設定されていますが、magpie で有効な ChatGPT アカウントはどれもこのモデルを提供していません。" }, + de: { unset: "Modell der Subagenten: das Modell, das der Haupt-Agent anfordert", def: null, set: "Modell der Subagenten: GPT-6 Astra", + moved: "Codex hat einen Subagent mit gpt-5.5 angefordert; magpie hat ihn auf codex/gpt-6-astra gesetzt, das für seine Subagenten festgelegte Modell.", + kept: "Codex hat einen Subagent mit gpt-5.5 angefordert, und er blieb dort: Seine Subagenten sind auf codex/gpt-6-astra gesetzt, aber kein eingeschaltetes ChatGPT-Konto in magpie bietet es an." }, +}; + +const codex = '.row.agent[data-id="codex"]'; +const engines = process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"]; +const langs = process.env.LANGS ? process.env.LANGS.split(",") : Object.keys(W); + +for (const engine of engines) { + for (const lang of langs) { + for (const width of [1100, 560]) { + test(`${engine} ${lang} ${width}px: Codex's subagent model is a square opening a ChatGPT account's models`, async (t) => { + const w = W[lang]; + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const page = await browser.newPage({ viewport: { width, height: 760 } }); + page.setDefaultTimeout(5000); + const errors = [], sets = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, sets)); + t.after(async () => { + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${width}-codex-subagent-model.png`) }); + } + await browser.close(); + }); + await page.goto("http://magpie.test/"); + await page.locator(codex).waitFor(); + await page.locator(`${codex} .ag-link`).click(); + await page.locator(`${codex} .ag-exp`).waitFor(); + + assert.equal(await page.locator('.row.agent[data-id="codex2"] .field[data-key="subagent_model"]').count(), 0, "a square with nothing to pick"); + const square = page.locator(`${codex} .extras-cell .field.extra[data-key="subagent_model"]`); + assert.equal(await square.count(), 1); + assert.equal(await page.locator(`${codex} .field:not(.extra)[data-key="subagent_model"]`).count(), 0, "a picker of its own"); + assert.equal(await square.getAttribute("aria-label"), w.unset); + assert.equal(await square.evaluate((e) => e.classList.contains("set")), false); + // the row fits: no page scrolls sideways, the square in the window + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, "the page scrolls sideways"); + const box = await square.boundingBox(); + assert(box && box.x >= 0 && box.x + box.width <= width, JSON.stringify(box)); + + const y = await page.evaluate(() => scrollY); + await square.click(); + await page.locator("#pop:not([hidden]) #list li").first().waitFor(); + assert.equal(await page.evaluate(() => scrollY), y, "the click scrolled the page"); + assert.equal(await page.locator("#pop").evaluate((e) => e.classList.contains("model-picker")), true); + assert.equal(await page.locator("select").count(), 0, "a native select"); + const items = await page.locator("#pop #list li").allInnerTexts(); + if (w.def) assert(items[0].includes(w.def), items[0]); + assert(items.some((s) => s.includes("GPT-6 Astra")), items.join(" | ")); + assert(!items.some((s) => s.includes("openai/")), "only a ChatGPT account's models"); + const pop = await page.locator("#pop").boundingBox(); + assert(pop.x >= 0 && pop.x + pop.width <= width, JSON.stringify(pop)); + const left = await page.evaluate(() => [...document.querySelectorAll("#pop *, .extras-cell *")].filter((e) => { + const s = getComputedStyle(e); + return parseFloat(s.borderLeftWidth) > 1 && s.borderLeftWidth !== s.borderRightWidth; + }).length); + assert.equal(left, 0, "a coloured left border"); + + await page.locator("#pop #list li", { hasText: "GPT-6 Astra" }).first().click(); + await page.waitForFunction(() => document.querySelector('.row.agent[data-id="codex"] .field.extra.set[data-key="subagent_model"]')); + assert.deepEqual(sets, [{ agent: "codex", field: "subagent_model", value: "codex/gpt-6-astra" }]); + assert.equal(await square.getAttribute("aria-label"), w.set + "\nme@example.com · via magpie"); + + // Default: every subagent on the model asked for again + await square.click(); + await page.locator("#pop:not([hidden]) #list li").first().waitFor(); + await page.locator("#pop #list li").first().click(); + await page.waitForFunction(() => !document.querySelector('.row.agent[data-id="codex"] .field.extra.set[data-key="subagent_model"]')); + assert.deepEqual(sets.at(-1), { agent: "codex", field: "subagent_model", value: "" }); + assert.equal(await page.evaluate(() => scrollY), y); + + // the Routing page's story: put on the model set, or why not + // (each from the list afresh: a narrow window shows the story over it) + for (const [i, said] of [[0, w.moved], [1, w.kept]]) { + await page.goto("http://magpie.test/?view=routing"); + await page.locator(".rt-days .rt-day").nth(1).click(); + await page.locator(".rt-req").nth(1).waitFor(); + await page.locator(".rt-req").nth(i).evaluate((e) => e.click()); + await page.waitForTimeout(300); + const story = await page.locator(".rt-steps").textContent(); + assert(story.includes(said), `${said}\nnot in\n${story}`); + // nothing says ChatGPT served the model the lead asked for + if (i === 0 && lang === "en") assert(!story.includes("ChatGPT serves it") && story.includes("only account on for codex/gpt-6-astra"), story); + } + assert.deepEqual(errors, []); + }); + } + } +} diff --git a/internal/provider/codex_subagent.go b/internal/provider/codex_subagent.go new file mode 100644 index 00000000..b934234c --- /dev/null +++ b/internal/provider/codex_subagent.go @@ -0,0 +1,90 @@ +package provider + +import ( + "fmt" + "strings" + + "github.com/yetone/magpie/internal/settings" +) + +// Codex's lead picks the model of each subagent it spawns by itself, in +// spawn_agent's model argument (willz on Discord: gpt-6-astra), and its +// own config can't overrule that pick: [agents] default_subagent_model is +// only the default for a spawn that names none. settings.CodexSubagentModel +// is the model magpie puts Codex's subagents on, whatever the lead asked +// for: the gateway rewrites a subagent's request (x-openai-subagent +// collab_spawn) to it. A subagent's task is sealed by the ChatGPT backend +// when its lead ran there, and only a ChatGPT account opens it again, so +// the model is one a ChatGPT account in magpie serves (CodexSubagentModels). +// "" leaves every subagent on the model its lead asked for. + +// CodexSubagentModel is the model Codex's subagents are put on, "" for +// the one the lead asks for. +func CodexSubagentModel() string { + return strings.TrimSpace(heldSettings().CodexSubagentModel) +} + +// CodexSubagentModels are the models a ChatGPT account in magpie serves, +// the ones Codex's subagents may be put on, in the catalog's order. +func CodexSubagentModels() []Entry { + var out []Entry + for _, e := range Served() { + if e.Group == "" && e.Provider.Account != nil && e.Provider.Account.Agent == "codex" { + out = append(out, e) + } + } + return out +} + +// CodexSubagentUnserved says why Codex's subagents can't be put on id now, +// "" when a ChatGPT account in magpie serves it. +func CodexSubagentUnserved(id string) string { + for _, e := range CodexSubagentModels() { + if e.ID == id { + return "" + } + } + if len(CodexSubagentModels()) == 0 { + return "no ChatGPT account is on in magpie" + } + return "no ChatGPT account on in magpie serves it" +} + +// SetCodexSubagentModel puts Codex's subagents on id, a ChatGPT account's +// model; "" leaves them on the model their lead asks for. One no ChatGPT +// account in magpie serves now is kept all the same — a backup or profile +// put back on a computer whose accounts differ, or an account signed in +// again later — and the gateway leaves subagents on the model asked for +// until one serves it, saying why in the route trace. Another provider's +// model is refused: it couldn't read a subagent's sealed task. +func SetCodexSubagentModel(id string) error { + id = strings.TrimSpace(id) + if id != "" && !codexAccountModel(id) { + return fmt.Errorf("Codex's subagents can't be put on %s: a subagent's task is sealed for ChatGPT accounts, so its model must be one a ChatGPT account in magpie serves (codex/…)", id) + } + s := settings.Load() + if s.CodexSubagentModel == id { + return nil + } + s.CodexSubagentModel = id + if err := settings.Save(s); err != nil { + return err + } + Changed() // a held settings read sees it + return nil +} + +// codexAccountModel says id names a model of a ChatGPT account's, served +// now or not: codex/, or one of a provider that is a ChatGPT +// account. +func codexAccountModel(id string) bool { + pid, model, ok := strings.Cut(id, "/") + if !ok || model == "" { + return false + } + if pid == "codex" { + return true + } + p, err := Find(pid) + return err == nil && p.Account != nil && p.Account.Agent == "codex" +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 1cf1a3a4..d0940aa7 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -311,6 +311,13 @@ type Settings struct { // codex-auto-review or the conversation's model at low effort. "" // leaves the list as it was. CodexAutoReview string `json:"codexAutoReview,omitempty"` + // CodexSubagentModel is the model the gateway puts every subagent + // Codex spawns on (a request x-openai-subagent names collab_spawn), + // whatever model its lead asked for in spawn_agent: a model a ChatGPT + // account in magpie serves (provider/model), since a subagent's task + // is sealed for one (willz on Discord). "" leaves each on the model its + // lead asked for. + CodexSubagentModel string `json:"codexSubagentModel,omitempty"` // FullContext has Codex and Claude Code told a model's whole context // window. Off, a window above WorkingWindow is told as WorkingWindow, // so they compact a long conversation there instead of sending ever @@ -957,6 +964,10 @@ func Save(s Settings) error { if s.CodexAutoReview != "" && !strings.Contains(s.CodexAutoReview, "/") { return fmt.Errorf("the model for Codex's auto-review must be a model's id such as openai/gpt-5-mini, not %q", s.CodexAutoReview) } + s.CodexSubagentModel = strings.TrimSpace(s.CodexSubagentModel) + if s.CodexSubagentModel != "" && !strings.Contains(s.CodexSubagentModel, "/") { + return fmt.Errorf("the model for Codex's subagents must be a model's id such as codex/gpt-5.5, not %q", s.CodexSubagentModel) + } s.Searcher = strings.TrimSpace(s.Searcher) if s.SearchFirst = strings.TrimSpace(s.SearchFirst); s.SearchFirst == SearchFirstModel { s.SearchFirst = ""