From 14ffdb65b051573da209701bd095189663f494cd Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Wed, 20 May 2026 21:24:19 +0200 Subject: [PATCH] Expose OpenAI Codex as separate provider --- internal/agent/args.go | 2 +- internal/agent/build.go | 30 ++++++++++---- internal/agent/cli.go | 2 +- internal/agent/config.go | 5 +++ internal/agent/modes/interactive.go | 25 +++++++++--- internal/agent/modes/login_dialog.go | 27 ++++++++---- internal/auth/manager.go | 50 ++++++++++++++--------- internal/auth/store.go | 45 ++++++++++++++++++-- internal/provider/models.go | 61 ++++++++++++++++++++++------ internal/provider/openai_codex.go | 14 +++++-- internal/provider/usermodels.go | 5 +-- 11 files changed, 200 insertions(+), 66 deletions(-) diff --git a/internal/agent/args.go b/internal/agent/args.go index 47897ec..499444e 100644 --- a/internal/agent/args.go +++ b/internal/agent/args.go @@ -357,7 +357,7 @@ func PrintHelp(version string) { row{"zot tg ...", "short alias for telegram-bot"}, ) section("provider and model flags", - row{"--provider", "provider to use (anthropic|openai|kimi|deepseek|google|ollama)"}, + row{"--provider", "provider to use (anthropic|openai|openai-codex|kimi|deepseek|google|ollama)"}, row{"--model ID", "model id (see --list-models)"}, row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, row{"--base-url URL", "override provider api base url"}, diff --git a/internal/agent/build.go b/internal/agent/build.go index b761f20..b92a861 100644 --- a/internal/agent/build.go +++ b/internal/agent/build.go @@ -132,6 +132,8 @@ func defaultModelForProvider(prov string) string { switch prov { case "openai": return "gpt-5" + case "openai-codex": + return "gpt-5.5" case "kimi": return "kimi-for-coding" case "deepseek": @@ -156,13 +158,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { // User-requested provider (explicit > config > default). provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic") - if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" { + if provName != "anthropic" && provName != "openai" && provName != "openai-codex" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" { // Unknown provider (maybe removed or renamed). Fall back to // the first provider that has credentials, or anthropic. provName = "anthropic" if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil { provName = "openai" } + if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil { + provName = "openai-codex" + } if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { provName = "kimi" } @@ -200,7 +205,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { // never shows a "not logged in" banner. userPickedProvider := args.Provider != "" if credErr != nil && !userPickedProvider && provName != "ollama" { - for _, other := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { + for _, other := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} { if other == provName { continue } @@ -217,6 +222,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { switch provName { case "openai": model = "gpt-5" + case "openai-codex": + model = "gpt-5.5" case "kimi": model = "kimi-for-coding" case "deepseek": @@ -236,6 +243,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { switch provName { case "openai": model = "gpt-5" + case "openai-codex": + model = "gpt-5.5" case "kimi": model = "kimi-for-coding" case "deepseek": @@ -521,11 +530,10 @@ func (r Resolved) NewClient() provider.Client { case "deepseek": return provider.NewDeepSeek(r.Credential, r.BaseURL) case "openai": - if r.AuthMethod == "oauth" { - inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) - return r.wrapWithRefresh(inner) - } return provider.NewOpenAI(r.Credential, r.BaseURL) + case "openai-codex": + inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) + return r.wrapWithRefresh(inner) case "google": // API-key only path. Gemini Generative Language API. return provider.NewGemini(r.Credential, r.BaseURL) @@ -543,11 +551,15 @@ func (r Resolved) NewClient() provider.Client { // sessions (hours) silently fail when the 1-hour token expires. func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { provName := r.Provider + tokenProvider := provName + if provName == "openai-codex" { + tokenProvider = "openai" + } baseURL := r.BaseURL accountID := r.AccountID refreshFn := func(ctx context.Context) (string, error) { - tok, err := refreshIfExpired(provName, loadOAuthToken(provName)) + tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider)) if err != nil { return "", err } @@ -556,7 +568,7 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { factory := func(token string) provider.Client { switch provName { - case "openai": + case "openai-codex": return provider.NewOpenAICodex(token, accountID, baseURL) case "kimi": return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders()) @@ -671,7 +683,7 @@ func kimiCodeHeaders() map[string]string { func envVarName(provider string) string { switch provider { - case "openai": + case "openai", "openai-codex": return "OPENAI" case "kimi": return "KIMI" diff --git a/internal/agent/cli.go b/internal/agent/cli.go index 936400c..bb714ba 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -902,7 +902,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { BuildAgentForRescue: buildAgentForRescue, LoggedInProviders: func() []string { var out []string - for _, p := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { + for _, p := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} { if _, _, err := ResolveCredential(p, ""); err == nil { out = append(out, p) } diff --git a/internal/agent/config.go b/internal/agent/config.go index 7b0f98e..96b52ce 100644 --- a/internal/agent/config.go +++ b/internal/agent/config.go @@ -141,6 +141,10 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s if v := os.Getenv("OPENAI_API_KEY"); v != "" { return v, "apikey", "", nil } + case "openai-codex": + // ChatGPT/Codex subscription route. It intentionally ignores + // OPENAI_API_KEY so users can keep both OpenAI API and Codex + // subscription credentials configured and choose by provider. case "kimi": if v := os.Getenv("KIMI_API_KEY"); v != "" { return v, "apikey", "", nil @@ -180,6 +184,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s if c.OpenAI.APIKey != "" { return c.OpenAI.APIKey, "apikey", "", nil } + case "openai-codex": if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) return tok.AccessToken, "oauth", tok.AccountID, nil diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index e379b4d..8530ea1 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -2907,7 +2907,7 @@ func (i *Interactive) openLogoutDialog() { } var items []logoutItem - for _, p := range []string{"anthropic", "openai", "kimi", "google"} { + for _, p := range []string{"anthropic", "kimi", "google"} { if creds.Has(p) { method := creds.Method(p) if method == "oauth" { @@ -2920,6 +2920,12 @@ func (i *Interactive) openLogoutDialog() { }) } } + if creds.OpenAI.APIKey != "" { + items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"}) + } + if creds.OpenAI.OAuth != nil { + items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"}) + } if len(items) == 0 { i.mu.Lock() i.statusOK = "no credentials stored; already logged out" @@ -2960,12 +2966,12 @@ func (i *Interactive) doLogout(target string) { var providers []string switch target { case "", "all": - providers = []string{"anthropic", "openai", "kimi", "google"} - case "anthropic", "openai", "kimi", "google": + providers = []string{"anthropic", "openai", "openai-codex", "kimi", "google"} + case "anthropic", "openai", "openai-codex", "kimi", "google": providers = []string{target} default: i.mu.Lock() - i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, google, or all)" + i.statusErr = "unknown provider: " + target + " (use anthropic, openai, openai-codex, kimi, google, or all)" i.mu.Unlock() return } @@ -2973,7 +2979,16 @@ func (i *Interactive) doLogout(target string) { var errs []string clearedCurrent := false for _, p := range providers { - if err := store.Clear(p); err != nil { + var err error + switch p { + case "openai": + err = store.ClearAPIKey("openai") + case "openai-codex": + err = store.ClearOAuth("openai") + default: + err = store.Clear(p) + } + if err != nil { errs = append(errs, p+": "+err.Error()) continue } diff --git a/internal/agent/modes/login_dialog.go b/internal/agent/modes/login_dialog.go index f2c0168..4e2137c 100644 --- a/internal/agent/modes/login_dialog.go +++ b/internal/agent/modes/login_dialog.go @@ -28,7 +28,7 @@ const ( type loginDialog struct { step loginStep method string // "apikey" | "oauth" - provider string // "anthropic" | "openai" | "kimi" | "google" + provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google" message string success bool url string @@ -39,8 +39,8 @@ type loginDialog struct { // provider, captured when Open() runs. Rendered above the // method picker so the user can see whether they're already // logged in (and how) before starting a new flow. Keys: - // "anthropic", "openai", "kimi", "google". Value is "apikey", "oauth", or "" - // (not logged in). + // "anthropic", "openai", "openai-codex", "kimi", "google". Value is + // "apikey", "oauth", or "" (not logged in). status map[string]string } @@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) { d.success = false d.url = "" d.cursor = 0 - d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "deepseek": "", "google": ""} + d.status = map[string]string{"anthropic": "", "openai": "", "openai-codex": "", "kimi": "", "deepseek": "", "google": ""} // Best-effort: if the auth file can't be read, treat every // provider as not-logged-in. The status line just won't show // anything useful in that case, which is fine — the user @@ -73,7 +73,14 @@ func (d *loginDialog) Open(zotHome string) { path := filepath.Join(zotHome, "auth.json") if creds, err := auth.NewStore(path).Load(); err == nil { d.status["anthropic"] = creds.Method("anthropic") - d.status["openai"] = creds.Method("openai") + d.status["openai"] = "" + if creds.OpenAI.APIKey != "" { + d.status["openai"] = "apikey" + } + d.status["openai-codex"] = "" + if creds.OpenAI.OAuth != nil { + d.status["openai-codex"] = "oauth" + } d.status["kimi"] = creds.Method("kimi") d.status["deepseek"] = creds.Method("deepseek") d.status["google"] = creds.Method("google") @@ -205,7 +212,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { // subscription product at all). func providersForMethod(method string) []string { if method == "oauth" { - return []string{"anthropic", "openai", "kimi"} + return []string{"anthropic", "openai-codex", "kimi"} } return []string{"anthropic", "openai", "kimi", "deepseek", "google"} } @@ -216,7 +223,9 @@ func providerLabel(id string) string { case "anthropic": return "Anthropic (Claude Pro/Max)" case "openai": - return "OpenAI (ChatGPT Plus/Pro)" + return "OpenAI" + case "openai-codex": + return "OpenAI Codex (ChatGPT Plus/Pro)" case "kimi": return "Kimi Code" case "deepseek": @@ -240,10 +249,11 @@ func providerLabel(id string) string { func (d *loginDialog) renderStatusLines(th tui.Theme) []string { anth := d.status["anthropic"] op := d.status["openai"] + codex := d.status["openai-codex"] kimi := d.status["kimi"] ds := d.status["deepseek"] goog := d.status["google"] - if anth == "" && op == "" && kimi == "" && ds == "" && goog == "" { + if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" { return nil } row := func(id, method string) string { @@ -265,6 +275,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { return []string{ row("anthropic", anth), row("openai", op), + row("openai-codex", codex), row("kimi", kimi), row("deepseek", ds), row("google", goog), diff --git a/internal/auth/manager.go b/internal/auth/manager.go index aa77868..4b61302 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -34,9 +34,11 @@ type Manager struct { oauthCtx context.Context oauthCancel context.CancelFunc - manualOp *OAuthProvider - manualPKCE PKCE - manualState string + manualOp *OAuthProvider + manualStoreProvider string + manualEventProvider string + manualPKCE PKCE + manualState string } // NewManager returns a Manager bound to store. @@ -133,18 +135,20 @@ func (m *Manager) StartOAuth(provider string) (string, error) { if provider == "kimi" { return m.StartKimiDeviceOAuth() } + storeProvider := provider var op OAuthProvider switch provider { case "anthropic": op = AnthropicOAuth - case "openai": + case "openai", "openai-codex": op = OpenAIOAuth + storeProvider = "openai" case "google": return "", fmt.Errorf("google login is api-key only; use api key login for gemini") case "deepseek": return "", fmt.Errorf("deepseek login is api-key only; use api key login") default: - return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") + return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google") } m.mu.Lock() @@ -178,13 +182,13 @@ func (m *Manager) StartOAuth(provider string) (string, error) { m.oauthCancel = cancel m.mu.Unlock() - go m.awaitOAuth(ctx, op, cs, pkce, state) + go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state) go m.maybeOpen(authURL) m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL}) return authURL, nil } -func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *CallbackServer, pkce PKCE, state string) { +func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvider, eventProvider string, cs *CallbackServer, pkce PKCE, state string) { defer cs.Shutdown() waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Minute) @@ -192,12 +196,12 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback res, err := cs.Result(waitCtx) if err != nil { if ctx.Err() == nil { - m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: "timeout waiting for callback"}) + m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"}) } return } if res.Err != nil { - m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: res.Err.Error()}) + m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()}) return } @@ -205,14 +209,14 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback defer exCancel() tok, err := op.Exchange(exCtx, res.Code, res.State, pkce) if err != nil { - m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) + m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) return } - if err := m.store.SetOAuth(op.Name, *tok); err != nil { - m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) + if err := m.store.SetOAuth(storeProvider, *tok); err != nil { + m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) return } - m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) + m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) } // StartKimiDeviceOAuth starts Kimi Code's device-code subscription login. @@ -258,18 +262,20 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { if provider == "kimi" { return m.StartKimiDeviceOAuth() } + storeProvider := provider var op OAuthProvider switch provider { case "anthropic": op = AnthropicManualOAuth - case "openai": + case "openai", "openai-codex": op = OpenAIOAuth + storeProvider = "openai" case "google": return "", fmt.Errorf("google login is api-key only; use api key login for gemini") case "deepseek": return "", fmt.Errorf("deepseek login is api-key only; use api key login") default: - return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") + return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google") } pkce, err := NewPKCE() @@ -283,6 +289,8 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { m.mu.Lock() m.manualOp = &op + m.manualStoreProvider = storeProvider + m.manualEventProvider = provider m.manualPKCE = pkce m.manualState = state m.mu.Unlock() @@ -297,6 +305,8 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { m.mu.Lock() op := m.manualOp + storeProvider := m.manualStoreProvider + eventProvider := m.manualEventProvider pkce := m.manualPKCE state := m.manualState m.mu.Unlock() @@ -314,19 +324,21 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { defer cancel() tok, err := op.Exchange(exCtx, code, state, pkce) if err != nil { - m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) + m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) return err } - if err := m.store.SetOAuth(op.Name, *tok); err != nil { - m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) + if err := m.store.SetOAuth(storeProvider, *tok); err != nil { + m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) return err } m.mu.Lock() m.manualOp = nil + m.manualStoreProvider = "" + m.manualEventProvider = "" m.manualPKCE = PKCE{} m.manualState = "" m.mu.Unlock() - m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) + m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) return nil } diff --git a/internal/auth/store.go b/internal/auth/store.go index 657000a..f73e7ca 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -23,8 +23,9 @@ type Credentials struct { DeepSeek ProviderCreds `json:"deepseek,omitempty"` } -// ProviderCreds holds credentials for a single provider. Only one of -// APIKey or OAuth is populated at a time. +// ProviderCreds holds credentials for a single provider. Most providers +// use either APIKey or OAuth; OpenAI may store both so the public API +// route and ChatGPT/Codex subscription route can coexist. type ProviderCreds struct { APIKey string `json:"api_key,omitempty"` OAuth *OAuthToken `json:"oauth,omitempty"` @@ -138,7 +139,9 @@ func (s *Store) SetAPIKey(provider, key string) error { return fmt.Errorf("unknown provider %q", provider) } p.APIKey = key - p.OAuth = nil + if provider != "openai" { + p.OAuth = nil + } return s.saveLocked(c) } @@ -154,7 +157,9 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error { if p == nil { return fmt.Errorf("unknown provider %q", provider) } - p.APIKey = "" + if provider != "openai" { + p.APIKey = "" + } p.OAuth = &tok return s.saveLocked(c) } @@ -175,6 +180,38 @@ func (s *Store) Clear(provider string) error { return s.saveLocked(c) } +// ClearAPIKey removes only the API key for provider, preserving any OAuth token. +func (s *Store) ClearAPIKey(provider string) error { + s.mu.Lock() + defer s.mu.Unlock() + c, err := s.loadLocked() + if err != nil { + return err + } + p := c.get(provider) + if p == nil { + return fmt.Errorf("unknown provider %q", provider) + } + p.APIKey = "" + return s.saveLocked(c) +} + +// ClearOAuth removes only the OAuth token for provider, preserving any API key. +func (s *Store) ClearOAuth(provider string) error { + s.mu.Lock() + defer s.mu.Unlock() + c, err := s.loadLocked() + if err != nil { + return err + } + p := c.get(provider) + if p == nil { + return fmt.Errorf("unknown provider %q", provider) + } + p.OAuth = nil + return s.saveLocked(c) +} + func (s *Store) saveLocked(c Credentials) error { if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err diff --git a/internal/provider/models.go b/internal/provider/models.go index d475fe7..62a0ae1 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -245,12 +245,8 @@ var Catalog = []Model{ }, // ---- Speculative: OpenAI ---- - // Context windows on the OpenAI gpt-5.x family differ by route: - // the direct API advertises 400k, the ChatGPT Codex OAuth backend - // caps at 272k. zot serves both auth modes from one catalog row - // per id, so we pin to the smaller number to keep the context-usage - // meter honest under subscription auth. Users on the direct API - // simply see a conservative headroom estimate. + // Public OpenAI API route. The ChatGPT/Codex subscription route is + // represented separately below as provider "openai-codex". { Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1", ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, @@ -279,12 +275,7 @@ var Catalog = []Model{ }, { Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini", - // ContextWindow: 400k on the OpenAI direct API, 272k on the - // ChatGPT Codex OAuth backend. We pin to the smaller Codex - // cap so the context-usage meter is honest under subscription - // auth; direct-API users simply see a conservative headroom - // estimate rather than an inflated one. - ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, + ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, Speculative: true, }, @@ -300,6 +291,52 @@ var Catalog = []Model{ PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, Speculative: true, }, + + // ---- OpenAI Codex / ChatGPT subscription backend ---- + // Same model ids as the OpenAI family, but routed through the + // ChatGPT Codex OAuth backend rather than api.openai.com. + { + Provider: "openai-codex", ID: "gpt-5.2", DisplayName: "GPT-5.2 Codex", + ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, + PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, + Speculative: true, + }, + { + Provider: "openai-codex", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3 Codex", + ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, + PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, + Speculative: true, + }, + { + Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark", + ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, + PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, + Speculative: true, + }, + { + Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4 Codex", + ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, + PriceInput: 2.50, PriceOutput: 15.00, PriceCacheRead: 0.25, + Speculative: true, + }, + { + Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini Codex", + ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, + PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, + Speculative: true, + }, + { + Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5 Codex", + ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, + PriceInput: 2.50, PriceOutput: 15.00, PriceCacheRead: 0.25, + Speculative: true, + }, + { + Provider: "openai-codex", ID: "gpt-5.5-mini", DisplayName: "GPT-5.5 mini Codex", + ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, + PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, + Speculative: true, + }, } // DefaultModel is used when the user does not specify one. diff --git a/internal/provider/openai_codex.go b/internal/provider/openai_codex.go index 0cc2a31..f16cb26 100644 --- a/internal/provider/openai_codex.go +++ b/internal/provider/openai_codex.go @@ -57,7 +57,7 @@ func NewOpenAICodex(token, accountID, baseURL string) Client { } } -func (c *codexClient) Name() string { return "openai" } +func (c *codexClient) Name() string { return "openai-codex" } // ---- Responses API wire types (subset needed for zot's surface) ---- @@ -146,7 +146,10 @@ type codexRequest struct { // ---- Request building ---- func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { - m, err := FindModel("openai", req.Model) + m, err := FindModel("openai-codex", req.Model) + if err != nil { + m, err = FindModel("openai", req.Model) + } if err != nil { return nil, err } @@ -324,8 +327,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re defer close(out) defer resp.Body.Close() - model, _ := FindModel("openai", req.Model) - out <- EventStart{Model: req.Model, Provider: "openai"} + model, _ := FindModel("openai-codex", req.Model) + if model.ID == "" { + model, _ = FindModel("openai", req.Model) + } + out <- EventStart{Model: req.Model, Provider: "openai-codex"} raw := make(chan sseEvent, 16) go readSSE(resp.Body, raw) diff --git a/internal/provider/usermodels.go b/internal/provider/usermodels.go index 7b835ca..b8302dc 100644 --- a/internal/provider/usermodels.go +++ b/internal/provider/usermodels.go @@ -70,11 +70,10 @@ func LoadUserModels(path string) []Model { var out []Model for providerName, prov := range file.Providers { - // Normalize provider name: strip suffixes like "-codex" so - // "openai-codex" maps to "openai". + // Normalize legacy transport aliases to their provider names. normalized := providerName switch providerName { - case "openai-codex", "openai-responses": + case "openai-responses": normalized = "openai" case "anthropic-messages": normalized = "anthropic"