From fa30d2b8ce9344f593dbe614bc5a0db721bea461 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 24 Sep 2026 23:38:50 +0800 Subject: [PATCH] =?UTF-8?q?subscriptions:=20a=20ChatGPT=20account=20lists?= =?UTF-8?q?=20the=20Codex=20models=20of=20its=20own=20plan,=20asked=20from?= =?UTF-8?q?=20ChatGPT=20with=20its=20own=20sign-in=20when=20models=20are?= =?UTF-8?q?=20refreshed=20=E2=80=94=20it=20was=20Codex=20CLI's=20cache,=20?= =?UTF-8?q?which=20is=20of=20whichever=20account=20Codex=20CLI=20last=20as?= =?UTF-8?q?ked=20with,=20or=20nothing=20if=20it=20never=20ran,=20so=20a=20?= =?UTF-8?q?Free=20account=20was=20offered=20models=20its=20plan=20answers?= =?UTF-8?q?=20with=20a=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/account.go | 8 ++- internal/provider/codex_models.go | 94 ++++++++++++++++++++++++++ internal/provider/codex_models_test.go | 43 ++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 internal/provider/codex_models.go create mode 100644 internal/provider/codex_models_test.go diff --git a/internal/provider/account.go b/internal/provider/account.go index c9aeaa5c..6af1a494 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -640,7 +640,13 @@ func codexAccount(home string) (Provider, bool) { acct.sign = codexSign(func(ctx context.Context) (string, string, error) { return codexToken(ctx, path) }) acct.body = codexBody acct.models = catalog.Codex - acct.fetch = func(context.Context) ([]catalog.Model, error) { return catalog.Codex(), nil } + acct.fetch = func(ctx context.Context) ([]catalog.Model, error) { + ms, err := codexModels(ctx, acct.sign) + if err != nil { + return nil, err + } + return ms, catalog.SaveLive("codex", CodexBase, ms) + } return Provider{ID: "codex", Name: "Codex", Icon: "codex-color", Responses: CodexBase, Website: "https://chatgpt.com/codex", Account: acct}, true } diff --git a/internal/provider/codex_models.go b/internal/provider/codex_models.go new file mode 100644 index 00000000..fd824a88 --- /dev/null +++ b/internal/provider/codex_models.go @@ -0,0 +1,94 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + + "github.com/yetone/magpie/internal/catalog" +) + +// codexClientVersion is the Codex CLI version the models list is asked for +// when Codex CLI has not asked itself yet: the list leaves out models newer +// than the client asking. +const codexClientVersion = "0.154.0" + +// codexModels asks the ChatGPT backend which Codex models the account's own +// plan has — a Free account lists fewer than a Plus or Pro one, and one it +// doesn't have fails with a 400. It is the list Codex CLI keeps in +// models_cache.json, but that one is of whichever account Codex CLI last +// asked with, if it ran at all. +func codexModels(ctx context.Context, sign func(context.Context, *http.Request, []byte) error) ([]catalog.Model, error) { + u := CodexBase + "/models?client_version=" + url.QueryEscape(codexCacheVersion()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + if err := sign(ctx, req, nil); err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ChatGPT models: %s", resp.Status) + } + ms := parseCodexModels(b) + if len(ms) == 0 { + return nil, errors.New("ChatGPT listed no Codex models") + } + return ms, nil +} + +// codexCacheVersion is the client version Codex CLI last asked with. +func codexCacheVersion() string { + home, _ := os.UserHomeDir() + var c struct { + ClientVersion string `json:"client_version"` + } + if b, err := os.ReadFile(filepath.Join(home, ".codex", "models_cache.json")); err == nil && json.Unmarshal(b, &c) == nil && c.ClientVersion != "" { + return c.ClientVersion + } + return codexClientVersion +} + +// parseCodexModels reads the backend's list, the listed ones in its order. +func parseCodexModels(b []byte) []catalog.Model { + var list struct { + Models []struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Visibility string `json:"visibility"` + Priority int `json:"priority"` + Levels []struct { + Effort string `json:"effort"` + } `json:"supported_reasoning_levels"` + } `json:"models"` + } + if json.Unmarshal(b, &list) != nil { + return nil + } + sort.SliceStable(list.Models, func(i, j int) bool { return list.Models[i].Priority < list.Models[j].Priority }) + var out []catalog.Model + for _, m := range list.Models { + if m.Slug == "" || m.Visibility == "hide" { + continue + } + mm := catalog.Model{ID: m.Slug, Name: m.DisplayName, Provider: "openai"} + for _, l := range m.Levels { + mm.Efforts = append(mm.Efforts, l.Effort) + } + out = append(out, mm) + } + return out +} diff --git a/internal/provider/codex_models_test.go b/internal/provider/codex_models_test.go new file mode 100644 index 00000000..3389ae24 --- /dev/null +++ b/internal/provider/codex_models_test.go @@ -0,0 +1,43 @@ +package provider + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// A ChatGPT account lists the models of its own plan, asked with its own +// token; the cache Codex CLI keeps may be another account's. +func TestCodexModelsOfTheAccount(t *testing.T) { + signIn(t) + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/backend-api/codex/models" || r.Header.Get("chatgpt-account-id") != "acct-1" || r.URL.Query().Get("client_version") == "" { + w.WriteHeader(400) + return + } + w.Write([]byte(`{"models": [ + {"slug": "gpt-5.6-luna", "display_name": "GPT-5.6 Luna", "visibility": "list", "priority": 2, + "supported_reasoning_levels": [{"effort": "low"}, {"effort": "medium"}]}, + {"slug": "codex-auto-review", "visibility": "hide", "priority": 1}]}`)) + })) + defer fake.Close() + old := CodexBase + CodexBase = fake.URL + "/backend-api/codex" + t.Cleanup(func() { CodexBase = old }) + + p, ok := find(All(), "codex") + if !ok { + t.Fatal("no codex account") + } + if ms := p.Available(); len(ms) != 1 || ms[0].ID != "gpt-5.5" { + t.Fatalf("before fetching, Codex CLI's cache: %+v", ms) + } + if _, err := p.Fetch(context.Background()); err != nil { + t.Fatal(err) + } + ms := p.Available() + if len(ms) != 1 || ms[0].ID != "gpt-5.6-luna" || len(ms[0].Efforts) != 2 { + t.Fatalf("after fetching: %+v", ms) + } +}