From bd5bf422eda5608c88884d1b11a97a1d7a9a571d Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 25 Sep 2026 06:49:01 +0800 Subject: [PATCH] =?UTF-8?q?codex:=20a=20ChatGPT=20account=20out=20of=20all?= =?UTF-8?q?owance=20gets=20magpie=20as=20a=20provider=20of=20Codex's,=20an?= =?UTF-8?q?d=20a=20routing=20group=20is=20named=20as=20one=20in=20the=20Co?= =?UTF-8?q?dex=20app's=20list=20=E2=80=94=20signed=20in=20through=20openai?= =?UTF-8?q?=5Fbase=5Furl,=20the=20Codex=20app=20won't=20send=20anything=20?= =?UTF-8?q?once=20the=20account=20has=20used=20its=20allowance=20up,=20a?= =?UTF-8?q?=20magpie=20model's=20request=20too,=20so=20magpie=20is=20then?= =?UTF-8?q?=20wired=20as=20it=20is=20when=20signed=20out=20(model=5Fprovid?= =?UTF-8?q?er,=20as=20the=20account's=20own=20models=20can't=20run=20anywa?= =?UTF-8?q?y);=20and=20the=20gateway's=20model=20list=20for=20the=20signed?= =?UTF-8?q?-in=20app=20named=20a=20group=20after=20its=20first=20member's?= =?UTF-8?q?=20provider=20("z-ai/glm-5.3=20=C2=B7=20nvidia"),=20reading=20a?= =?UTF-8?q?s=20that=20provider's=20own=20model,=20where=20it=20now=20says?= =?UTF-8?q?=20routing=20group,=20as=20magpie's=20other=20lists=20do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/codex.go | 19 +++++++++++++++++-- internal/agent/codex_route_test.go | 18 ++++++++++++++++++ internal/gateway/codex_backend.go | 7 ++++++- internal/gateway/codex_backend_test.go | 26 ++++++++++++++++++++++++++ internal/provider/login_usage.go | 17 +++++++++++++++++ 5 files changed, 84 insertions(+), 3 deletions(-) diff --git a/internal/agent/codex.go b/internal/agent/codex.go index 88c2e62d..1b43b74e 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -1,11 +1,13 @@ package agent import ( + "context" "encoding/json" "os" "path/filepath" "slices" "strings" + "time" "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/codexcat" @@ -22,7 +24,9 @@ import ( // answers the rest — the model list included, so magpie's models join // OpenAI's in /model. Not signed in, the built-in provider can't run, and // magpie is a provider of its own: a [model_providers.magpie] table, -// `model_provider = "magpie"`, and a model catalog file for /model. +// `model_provider = "magpie"`, and a model catalog file for /model. So it +// is too for a ChatGPT account that has used its allowance up, which the +// Codex app won't send anything for, whoever serves the model. func codex(home string) *Agent { dir := filepath.Join(home, ".codex") @@ -91,7 +95,10 @@ func codex(home string) *Agent { stash(map[string]string{"codex.model": get("model"), "codex.effort": get("model_reasoning_effort"), "codex.provider": get("model_provider"), "codex.catalog": get("model_catalog_json")}) } - if codexSignedIn(dir) { + // a ChatGPT account out of allowance keeps the Codex app from + // sending at all, a magpie model's request too; as a provider + // of Codex's own, magpie is past that + if codexSignedIn(dir) && !codexUsedUp() { if err := dropProvider(); err != nil { return err } @@ -246,6 +253,14 @@ func isCodexGateway(u string) bool { return strings.HasPrefix(u, "http://127.0.0.1:") && strings.HasSuffix(strings.TrimSuffix(u, "/"), gateway.CodexPath) } +// codexUsedUp reports whether the ChatGPT account Codex is signed in to +// has used up its allowance. A var so tests can say. +var codexUsedUp = func() bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return provider.CodexUsedUp(ctx) +} + // codexSignedIn reports whether Codex has a sign-in of its own, a ChatGPT // account or an API key, which its built-in OpenAI provider needs. func codexSignedIn(dir string) bool { diff --git a/internal/agent/codex_route_test.go b/internal/agent/codex_route_test.go index b30f6005..07200b83 100644 --- a/internal/agent/codex_route_test.go +++ b/internal/agent/codex_route_test.go @@ -16,6 +16,9 @@ func codexHome(t *testing.T, auth, config string) (home string, read func() stri t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) t.Setenv("CODEX_HOME", "") + usedUp := codexUsedUp + codexUsedUp = func() bool { return false } + t.Cleanup(func() { codexUsedUp = usedUp }) dir := filepath.Join(home, ".codex") os.MkdirAll(dir, 0o755) if auth != "" { @@ -94,3 +97,18 @@ func TestCodexSignedOutUsesProvider(t *testing.T) { t.Fatalf("reset:\n%s", cfg) } } + +// A ChatGPT account that has used its allowance up keeps the Codex app from +// sending, so magpie is a provider of Codex's then, as when signed out. +func TestCodexUsedUpUsesProvider(t *testing.T) { + home, read := codexHome(t, `{"tokens":{"access_token":"x","id_token":"x.e30.x"}}`, "") + codexUsedUp = func() bool { return true } + if err := codex(home).Fields[0].Set("fake/m1"); err != nil { + t.Fatal(err) + } + cfg := read() + if strings.Contains(cfg, "openai_base_url") || !strings.Contains(cfg, `model_provider = "magpie"`) || + !strings.Contains(cfg, "[model_providers.magpie]") || !strings.Contains(cfg, `model = "fake/m1"`) { + t.Fatalf("\n%s", cfg) + } +} diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index ba6d8b79..c895c939 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -260,7 +260,12 @@ func (s *Server) codexModels(w http.ResponseWriter, r *http.Request) { if e.Provider.Account != nil && e.Provider.Account.Agent == "codex" { continue } - ms = append(ms, catalog.Model{ID: e.ID, Name: e.Name + " · " + e.Provider.Name, Efforts: e.Efforts}) + // a group answers for its first member but is not that provider's + by := e.Provider.Name + if e.Group != "" { + by = "routing group" + } + ms = append(ms, catalog.Model{ID: e.ID, Name: e.Name + " · " + by, Efforts: e.Efforts}) } if etag != "" { w.Header().Set("ETag", etag) diff --git a/internal/gateway/codex_backend_test.go b/internal/gateway/codex_backend_test.go index 02c12ffb..5b117406 100644 --- a/internal/gateway/codex_backend_test.go +++ b/internal/gateway/codex_backend_test.go @@ -199,3 +199,29 @@ func TestCodexWebSocketUpgradeRequired(t *testing.T) { t.Errorf("%d", rec.Code) } } + +// A routing group in Codex's model list is named as one, not as its first +// member's provider, which would read as that provider's own model. +func TestCodexModelsNameGroups(t *testing.T) { + setup(t, provider.Chat, &fake{t: t}) + chatgpt(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"models":[]}`) }) + if err := provider.SaveGroup(provider.Group{Name: "G", Members: []string{"fake/m1"}}); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + New().Handler().ServeHTTP(rec, httptest.NewRequest("GET", CodexPath+"/models", nil)) + var list struct { + Models []struct { + Slug string `json:"slug"` + Name string `json:"display_name"` + } `json:"models"` + } + json.Unmarshal(rec.Body.Bytes(), &list) + names := map[string]string{} + for _, m := range list.Models { + names[m.Slug] = m.Name + } + if names["group/g"] != "G · routing group" || !strings.HasSuffix(names["fake/m1"], " · Fake") { + t.Fatalf("%v", names) + } +} diff --git a/internal/provider/login_usage.go b/internal/provider/login_usage.go index 1b4b92db..95b182aa 100644 --- a/internal/provider/login_usage.go +++ b/internal/provider/login_usage.go @@ -114,3 +114,20 @@ func loginQuota(ctx context.Context, l Login) SubscriptionQuota { } return q } + +// CodexUsedUp reports whether the ChatGPT account Codex is signed in to has +// used up its allowance for now; false when that isn't known. +func CodexUsedUp(ctx context.Context) bool { + for _, l := range Logins("codex") { + if !l.Active { + continue + } + q := loginQuota(ctx, l) + for _, w := range q.Windows { + if !w.Aside && w.Model == "" && w.Used >= 100 { + return true + } + } + } + return false +}