agents, gateway: Codex's subagents go on a model set in its row, a ChatGPT account's (willz on Discord)
Codex's lead names each subagent's model itself in spawn_agent, and
Codex's [agents] config can't overrule it. The Agents page's Codex row
now has a "subagent model" square (the app's model picker, Default
first). Its options are only models a ChatGPT account in magpie serves,
because a subagent's task is sealed for ChatGPT accounts.
- settings.CodexSubagentModel holds it. It is an agent Field
(subagent_model), so the TUI/CLI, profiles, davsync's "agents" part
and WSL twins carry it too. The setter refuses another provider's
model. It keeps a ChatGPT account's model that isn't served now, so a
backup restored on another computer still applies (ApplyFields stops
at the first failed Set). POST /api/settings keeps it.
- The gateway (codexSubagentPick) moves a collab_spawn request onto it.
codexBackend does this for Codex's own bare models; serve() does it
for any other model. When no ChatGPT account on in magpie serves it,
or a held gateway key may not use it, the request stays on the model
asked for. Route.Subagent records asked/to/kept. The lead's turns,
titles, guardian and the rest are never moved. Off (the default)
changes nothing.
- Routing page: kindWhy says the subagent was put on the model set, or
stayed on its own and why. "only account on for" names the model it
went on. Strings added in zh, zh-TW, ja and de.
- docs/subsystems: gateway-routing.md and agent-wiring.md describe it.
Run (darwin, rebased on origin/main):
- gofmt -l internal/: clean.
- go vet -tags nogui on gateway, provider, agent, gui and settings:
darwin, GOOS=linux and GOOS=windows.
- go build ./...
- HOME=<temp> go test -tags nogui ./...: all ok.
TestSettingsSaveKeepsWhatItDoesNotSend flagged the new field; it is
now set there.
- GUI tests, Chromium + WebKit:
- codex-subagent-model.test.cjs: 20/20 (en, zh, zh-TW, ja, de at
1100 and 560 px).
- Also green: routing-kind, subagent-effort, codex-memories,
codex-memories-settings, i18n-keys, gui-ja, gui-de, gui-zh-tw and
click-scroll.
Failing without the change (codexSubagentPick returning nil):
- TestCodexSubagentPutOnTheModelSet
- TestCodexSubagentOnAnotherProviderPutOnTheModelSet
- TestCodexSubagentModelNotServedKeepsTheAsked
- TestCodexSubagentModelHeldKeyKeepsTheAsked
TestCodexSubagentModelLeavesOtherRequests guards the other side: the
lead's turns, titles and guardian stay untouched.
Not tried with a real Codex CLI and ChatGPT account. A subagent spawn
needs a signed-in Codex home, and this session may not touch the live
~/.codex. The fake ChatGPT backend tests cover both routes.
This commit is contained in:
1 parent
aa82288fb6
commit
189586612e
16 files changed
+688
-9
No files matched your search
@@ -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/<model>`. 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/<name>/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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<model>, 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"
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
Reference in new issue
Block a user