From ab47042ba8691f2e3369be94eb6932fe7c19461b Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 6 Oct 2026 02:25:01 +0800 Subject: [PATCH] agent: Codex on one of magpie's models beside its ChatGPT sign-in stays connected when Codex writes one of its own models back, and connecting keeps the model it was on through magpie where it can't join (#940, xybio) Picked "via magpie" on a ChatGPT-signed Codex, a magpie model is set beside the sign-in: openai_base_url to the gateway, the magpie table, no model_provider. When Codex itself (its /model, or a Codex app still running on its old pick) then wrote one of its own models back (gpt-6.1-sol), Wired found nothing to say magpie had put it there, so the row read Not connected while its config still pointed at magpie's gateway. Set so, Codex keeps a codex.beside mark (gone when magpie becomes its provider, on a pick of its own through the picker, or on disconnect), and Agent.Beside makes Wired true on it; the model written back is still told as a change from outside (#750's drift stays). Connecting where Codex can't join (its ChatGPT allowance used up, or a model_provider of its own) went to the first of magpie's other models (Grok 4.7, "magpie doesn't serve the model it was on"), since Codex's field leaves out magpie's codex/* models: Agent.OwnVia now finds the model it is on as magpie serves it on the user's ChatGPT account (codex/gpt-6.1-sol), and Connect keeps it there. TestCodexOwnPickAfterMagpieModelStaysConnected and TestCodexConnectKeepsItsOwnModelThroughMagpie are new; with the change taken out the first reads the reported config (model gpt-6.1-sol, openai_base_url, [model_providers.magpie], no model_provider) as not connected and the second connects to fake/m1 "first". Tested in temp HOMEs only. go vet, go build (GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (internal/proc TestUserPath timed out waiting on a login shell under load once, and passes on its own). --- internal/agent/agent.go | 11 ++++ internal/agent/applied.go | 8 ++- internal/agent/codex.go | 18 +++++- internal/agent/codex_connected_test.go | 82 ++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 internal/agent/codex_connected_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e850a77f..408d9008 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -121,6 +121,17 @@ type Agent struct { // Joined reports an agent Join connected: magpie is in its config // though no field is on one of magpie's models. Joined func() bool + // Beside reports an agent set on one of magpie's models beside its own + // (Codex by the base URL beside its ChatGPT sign-in), now on one of its + // own written in by the agent: still connected, as joined, though the + // change is told as drift (#940). + Beside func() bool + // OwnVia is the catalog id magpie serves one of the agent's own models + // by on a sign-in of the user's, which its model field lists as its + // own rather than as magpie's ("codex/gpt-5.5" for Codex's gpt-5.5), + // "" for none: Connect keeps the agent on that model through magpie + // where it can't Join (#940: Codex went to an unrelated model). + OwnVia func(model string) string // Routed reports that the agent's config sends whatever model it // names to magpie's gateway (Codex's openai_base_url or magpie as its // provider), so a model's name the gateway takes as a routing group diff --git a/internal/agent/applied.go b/internal/agent/applied.go index 90a9525e..8159f240 100644 --- a/internal/agent/applied.go +++ b/internal/agent/applied.go @@ -347,7 +347,7 @@ func (a *Agent) Wired() bool { if a.Native != nil { return a.Native.Read().Provider == "connected" } - if a.Joined != nil && a.Joined() { + if a.Joined != nil && a.Joined() || a.Beside != nil && a.Beside() { return true } vals := a.Values() @@ -544,6 +544,12 @@ func (a *Agent) connect() (Connection, error) { sub = o.Value } } + // the model it is on, of its own, as magpie serves it on a sign-in of + // the user's, which its field lists as its own (Codex's on a ChatGPT + // account it can't join, #940) + if same == "" && alike == "" && cur != "" && a.OwnVia != nil { + same = a.OwnVia(cur) + } how := "first" switch { case same != "": diff --git a/internal/agent/codex.go b/internal/agent/codex.go index ea463940..61bbf4d0 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -113,13 +113,20 @@ func codexIn(at place) *Agent { // joined: connected by Join, on a model of Codex's own (its last pick) // with magpie's beside it, by the base URL joined := func() bool { return viaBase() && stashLoad()[at.key("codex.joined")] == "1" } + // beside: set on one of magpie's models beside the ChatGPT sign-in, by + // the base URL, Codex's own models in its list too: one of them written + // in by Codex (its /model, or a Codex app still running on its old + // pick) leaves it connected, its config still on magpie's gateway + // (#940: it read as not connected), the change told as drift. One + // picked on the Agents page still takes magpie out (set). + beside := func() bool { return viaBase() && stashLoad()[at.key("codex.beside")] == "1" } // magpie is in Codex's config: on one of magpie's models, or joined; // or, routed, on the model of the group magpie set it to as Codex // spells it (gpt-6.1-sol for group/auto-gpt-6-1-sol, #750), which the // gateway takes as that group wired := func() bool { m := get("model") - return isMagpie(m) || joined() || routed() && setAsGroup(cmp.Or(at.id, "codex"), "model", m) + return isMagpie(m) || joined() || beside() || routed() && setAsGroup(cmp.Or(at.id, "codex"), "model", m) } models := func() []catalog.Model { switch { @@ -476,7 +483,7 @@ func codexIn(at place) *Agent { // provider, catalog and effort); it answers the model Codex was on // then, for Unwire to go back to unroute := func() (string, error) { - forget(at.key("codex.out"), at.key("codex.joined")) + forget(at.key("codex.out"), at.key("codex.joined"), at.key("codex.beside")) if err := giveTables(); err != nil { return "", err } @@ -529,7 +536,7 @@ func codexIn(at place) *Agent { return err } os.Remove(catalogPath) - forget(at.key("codex.model"), at.key("codex.effort"), at.key("codex.provider"), at.key("codex.catalog"), at.key("codex.out"), at.key("codex.joined")) + forget(at.key("codex.model"), at.key("codex.effort"), at.key("codex.provider"), at.key("codex.catalog"), at.key("codex.out"), at.key("codex.joined"), at.key("codex.beside")) return nil } // magpie API: magpie stays Codex's provider whichever model is the @@ -577,6 +584,7 @@ func codexIn(at place) *Agent { ); err != nil { return err } + stash(map[string]string{at.key("codex.beside"): "1"}) if err := takeTables(); err != nil { return err } @@ -587,6 +595,8 @@ func codexIn(at place) *Agent { } else { forget(at.key("codex.out")) } + // magpie is its provider now, not beside the sign-in + forget(at.key("codex.beside")) if err := putProvider(); err != nil { return err } @@ -707,7 +717,9 @@ func codexIn(at place) *Agent { return true, settle() }, Joined: joined, + Beside: beside, Routed: routed, + OwnVia: codexOwnViaMagpie, Sync: func() error { // model_provider = "magpie" left with its table gone (taken by // another tool, or a hand edit), which keeps Codex from loading diff --git a/internal/agent/codex_connected_test.go b/internal/agent/codex_connected_test.go new file mode 100644 index 00000000..be8a7cbd --- /dev/null +++ b/internal/agent/codex_connected_test.go @@ -0,0 +1,82 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/edit" +) + +// Codex signed in with ChatGPT, connected on one of magpie's models beside +// its sign-in, stays connected when Codex writes one of its own models in +// (its /model, or a Codex app still running on its old pick): its own +// models are in its list beside magpie's, as when it joined (#940: Codex +// read as not connected then, its config still on magpie's gateway). +// Disconnect leaves it on that model. Made magpie's provider (its +// allowance used up), it is not beside the sign-in. +func TestCodexOwnPickAfterMagpieModelStaysConnected(t *testing.T) { + home, read := codexHome(t, `{"tokens":{"access_token":"x","id_token":"x.e30.x"}}`, "model = \"gpt-5.5\"\n") + cx := codex(home) + if err := cx.Apply("model", "fake/m1"); err != nil { + t.Fatal(err) + } + if !cx.Wired() { + t.Fatalf("not connected on fake/m1:\n%s", read()) + } + if err := edit.SetTOMLTop(filepath.Join(home, ".codex", "config.toml"), edit.KV{Path: "model", Value: "gpt-6.1-sol"}); err != nil { + t.Fatal(err) + } + if !cx.Wired() { + t.Fatalf("Codex's own pick disconnected it:\n%s", read()) + } + // told as a change from outside, the row still connected + if d := cx.Drift(); d == nil || d.Kind != "replaced" || d.Now != "gpt-6.1-sol" { + t.Fatalf("drift: %+v", d) + } + if err := cx.Disconnect(); err != nil { + t.Fatal(err) + } + if cfg := read(); cx.Wired() || strings.Contains(cfg, "openai_base_url") || !strings.Contains(cfg, `model = "gpt-6.1-sol"`) { + t.Fatalf("disconnected:\n%s", cfg) + } + + // out of its allowance, magpie is its provider: an own model written + // back is a change from outside, not a pick beside magpie's + codexUsedUp = func() bool { return true } + if err := cx.Fields[0].Set("fake/m1"); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".codex", "config.toml") + if err := edit.SetTOMLTop(path, edit.KV{Path: "model", Value: "gpt-6.1-sol"}); err != nil { + t.Fatal(err) + } + if err := edit.DelTOMLTop(path, "model_provider"); err != nil { + t.Fatal(err) + } + if cx.Wired() { + t.Fatalf("still beside the sign-in after magpie became its provider:\n%s", read()) + } +} + +// Connected while Codex can't join (its ChatGPT account used up, or a +// provider of its own in config.toml), Codex stays on the model it was on +// as magpie serves it on the ChatGPT account, not the first of magpie's +// other models (#940: it went to Grok 4.7, "magpie doesn't serve the model +// it was on"). +func TestCodexConnectKeepsItsOwnModelThroughMagpie(t *testing.T) { + home, read := codexHome(t, `{"tokens":{"access_token":"x","id_token":"x.e30.x"}}`, "model = \"gpt-b\"\n") + os.WriteFile(filepath.Join(home, ".codex", "models_cache.json"), []byte(`{"models":[ + {"slug":"gpt-a","display_name":"A","priority":1}, + {"slug":"gpt-b","display_name":"B","priority":2}]}`), 0o644) + codexUsedUp = func() bool { return true } + cx := codex(home) + c, err := cx.ConnectHow() + if err != nil { + t.Fatal(err) + } + if c.How != "same" || c.Value != "codex/gpt-b" || !strings.Contains(read(), `model = "codex/gpt-b"`) { + t.Fatalf("connected %+v:\n%s", c, read()) + } +}