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 +}