Expose OpenAI Codex as separate provider
- commit
- 14ffdb65b051573da209701bd095189663f494cd
- author
- patriceckhart · · 2026-05-20 19:24 UTC
- parent
- f9d1425
- signature
- unsigned
- refs
- tag: v0.1.88
11 files · +200 -66
M internal/agent/args.go +1 -1
| @@ -357,7 +357,7 @@ func PrintHelp(version string) { | |||
| 357 | 357 | row{"zot tg ...", "short alias for telegram-bot"}, | |
| 358 | 358 | ) | |
| 359 | 359 | section("provider and model flags", | |
| 360 | - | row{"--provider", "provider to use (anthropic|openai|kimi|deepseek|google|ollama)"}, | |
| 360 | + | row{"--provider", "provider to use (anthropic|openai|openai-codex|kimi|deepseek|google|ollama)"}, | |
| 361 | 361 | row{"--model ID", "model id (see --list-models)"}, | |
| 362 | 362 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | |
| 363 | 363 | row{"--base-url URL", "override provider api base url"}, | |
| @@ -357,7 +357,7 @@ func PrintHelp(version string) { | |||||
| 357 | row{"zot tg ...", "short alias for telegram-bot"}, | 357 | row{"zot tg ...", "short alias for telegram-bot"}, | ||
| 358 | ) | 358 | ) | ||
| 359 | section("provider and model flags", | 359 | section("provider and model flags", | ||
| 360 | - | row{"--provider", "provider to use (anthropic|openai|kimi|deepseek|google|ollama)"}, | 360 | + | row{"--provider", "provider to use (anthropic|openai|openai-codex|kimi|deepseek|google|ollama)"}, |
| 361 | row{"--model ID", "model id (see --list-models)"}, | 361 | row{"--model ID", "model id (see --list-models)"}, | ||
| 362 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | 362 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | ||
| 363 | row{"--base-url URL", "override provider api base url"}, | 363 | row{"--base-url URL", "override provider api base url"}, | ||
M internal/agent/build.go +21 -9
| @@ -132,6 +132,8 @@ func defaultModelForProvider(prov string) string { | |||
| 132 | 132 | switch prov { | |
| 133 | 133 | case "openai": | |
| 134 | 134 | return "gpt-5" | |
| 135 | + | case "openai-codex": | |
| 136 | + | return "gpt-5.5" | |
| 135 | 137 | case "kimi": | |
| 136 | 138 | return "kimi-for-coding" | |
| 137 | 139 | case "deepseek": | |
| @@ -156,13 +158,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 156 | 158 | ||
| 157 | 159 | // User-requested provider (explicit > config > default). | |
| 158 | 160 | provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic") | |
| 159 | - | if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" { | |
| 161 | + | if provName != "anthropic" && provName != "openai" && provName != "openai-codex" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" { | |
| 160 | 162 | // Unknown provider (maybe removed or renamed). Fall back to | |
| 161 | 163 | // the first provider that has credentials, or anthropic. | |
| 162 | 164 | provName = "anthropic" | |
| 163 | 165 | if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil { | |
| 164 | 166 | provName = "openai" | |
| 165 | 167 | } | |
| 168 | + | if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil { | |
| 169 | + | provName = "openai-codex" | |
| 170 | + | } | |
| 166 | 171 | if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { | |
| 167 | 172 | provName = "kimi" | |
| 168 | 173 | } | |
| @@ -200,7 +205,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 200 | 205 | // never shows a "not logged in" banner. | |
| 201 | 206 | userPickedProvider := args.Provider != "" | |
| 202 | 207 | if credErr != nil && !userPickedProvider && provName != "ollama" { | |
| 203 | - | for _, other := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { | |
| 208 | + | for _, other := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} { | |
| 204 | 209 | if other == provName { | |
| 205 | 210 | continue | |
| 206 | 211 | } | |
| @@ -217,6 +222,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 217 | 222 | switch provName { | |
| 218 | 223 | case "openai": | |
| 219 | 224 | model = "gpt-5" | |
| 225 | + | case "openai-codex": | |
| 226 | + | model = "gpt-5.5" | |
| 220 | 227 | case "kimi": | |
| 221 | 228 | model = "kimi-for-coding" | |
| 222 | 229 | case "deepseek": | |
| @@ -236,6 +243,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 236 | 243 | switch provName { | |
| 237 | 244 | case "openai": | |
| 238 | 245 | model = "gpt-5" | |
| 246 | + | case "openai-codex": | |
| 247 | + | model = "gpt-5.5" | |
| 239 | 248 | case "kimi": | |
| 240 | 249 | model = "kimi-for-coding" | |
| 241 | 250 | case "deepseek": | |
| @@ -521,11 +530,10 @@ func (r Resolved) NewClient() provider.Client { | |||
| 521 | 530 | case "deepseek": | |
| 522 | 531 | return provider.NewDeepSeek(r.Credential, r.BaseURL) | |
| 523 | 532 | case "openai": | |
| 524 | - | if r.AuthMethod == "oauth" { | |
| 525 | - | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | |
| 526 | - | return r.wrapWithRefresh(inner) | |
| 527 | - | } | |
| 528 | 533 | return provider.NewOpenAI(r.Credential, r.BaseURL) | |
| 534 | + | case "openai-codex": | |
| 535 | + | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | |
| 536 | + | return r.wrapWithRefresh(inner) | |
| 529 | 537 | case "google": | |
| 530 | 538 | // API-key only path. Gemini Generative Language API. | |
| 531 | 539 | return provider.NewGemini(r.Credential, r.BaseURL) | |
| @@ -543,11 +551,15 @@ func (r Resolved) NewClient() provider.Client { | |||
| 543 | 551 | // sessions (hours) silently fail when the 1-hour token expires. | |
| 544 | 552 | func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | |
| 545 | 553 | provName := r.Provider | |
| 554 | + | tokenProvider := provName | |
| 555 | + | if provName == "openai-codex" { | |
| 556 | + | tokenProvider = "openai" | |
| 557 | + | } | |
| 546 | 558 | baseURL := r.BaseURL | |
| 547 | 559 | accountID := r.AccountID | |
| 548 | 560 | ||
| 549 | 561 | refreshFn := func(ctx context.Context) (string, error) { | |
| 550 | - | tok, err := refreshIfExpired(provName, loadOAuthToken(provName)) | |
| 562 | + | tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider)) | |
| 551 | 563 | if err != nil { | |
| 552 | 564 | return "", err | |
| 553 | 565 | } | |
| @@ -556,7 +568,7 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | |||
| 556 | 568 | ||
| 557 | 569 | factory := func(token string) provider.Client { | |
| 558 | 570 | switch provName { | |
| 559 | - | case "openai": | |
| 571 | + | case "openai-codex": | |
| 560 | 572 | return provider.NewOpenAICodex(token, accountID, baseURL) | |
| 561 | 573 | case "kimi": | |
| 562 | 574 | return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders()) | |
| @@ -671,7 +683,7 @@ func kimiCodeHeaders() map[string]string { | |||
| 671 | 683 | ||
| 672 | 684 | func envVarName(provider string) string { | |
| 673 | 685 | switch provider { | |
| 674 | - | case "openai": | |
| 686 | + | case "openai", "openai-codex": | |
| 675 | 687 | return "OPENAI" | |
| 676 | 688 | case "kimi": | |
| 677 | 689 | return "KIMI" | |
| @@ -132,6 +132,8 @@ func defaultModelForProvider(prov string) string { | |||||
| 132 | switch prov { | 132 | switch prov { | ||
| 133 | case "openai": | 133 | case "openai": | ||
| 134 | return "gpt-5" | 134 | return "gpt-5" | ||
| 135 | + | case "openai-codex": | |||
| 136 | + | return "gpt-5.5" | |||
| 135 | case "kimi": | 137 | case "kimi": | ||
| 136 | return "kimi-for-coding" | 138 | return "kimi-for-coding" | ||
| 137 | case "deepseek": | 139 | case "deepseek": | ||
| @@ -156,13 +158,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 156 | 158 | ||||
| 157 | // User-requested provider (explicit > config > default). | 159 | // User-requested provider (explicit > config > default). | ||
| 158 | provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic") | 160 | provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic") | ||
| 159 | - | if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" { | 161 | + | if provName != "anthropic" && provName != "openai" && provName != "openai-codex" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" { |
| 160 | // Unknown provider (maybe removed or renamed). Fall back to | 162 | // Unknown provider (maybe removed or renamed). Fall back to | ||
| 161 | // the first provider that has credentials, or anthropic. | 163 | // the first provider that has credentials, or anthropic. | ||
| 162 | provName = "anthropic" | 164 | provName = "anthropic" | ||
| 163 | if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil { | 165 | if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil { | ||
| 164 | provName = "openai" | 166 | provName = "openai" | ||
| 165 | } | 167 | } | ||
| 168 | + | if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil { | |||
| 169 | + | provName = "openai-codex" | |||
| 170 | + | } | |||
| 166 | if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { | 171 | if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { | ||
| 167 | provName = "kimi" | 172 | provName = "kimi" | ||
| 168 | } | 173 | } | ||
| @@ -200,7 +205,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 200 | // never shows a "not logged in" banner. | 205 | // never shows a "not logged in" banner. | ||
| 201 | userPickedProvider := args.Provider != "" | 206 | userPickedProvider := args.Provider != "" | ||
| 202 | if credErr != nil && !userPickedProvider && provName != "ollama" { | 207 | if credErr != nil && !userPickedProvider && provName != "ollama" { | ||
| 203 | - | for _, other := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { | 208 | + | for _, other := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} { |
| 204 | if other == provName { | 209 | if other == provName { | ||
| 205 | continue | 210 | continue | ||
| 206 | } | 211 | } | ||
| @@ -217,6 +222,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 217 | switch provName { | 222 | switch provName { | ||
| 218 | case "openai": | 223 | case "openai": | ||
| 219 | model = "gpt-5" | 224 | model = "gpt-5" | ||
| 225 | + | case "openai-codex": | |||
| 226 | + | model = "gpt-5.5" | |||
| 220 | case "kimi": | 227 | case "kimi": | ||
| 221 | model = "kimi-for-coding" | 228 | model = "kimi-for-coding" | ||
| 222 | case "deepseek": | 229 | case "deepseek": | ||
| @@ -236,6 +243,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 236 | switch provName { | 243 | switch provName { | ||
| 237 | case "openai": | 244 | case "openai": | ||
| 238 | model = "gpt-5" | 245 | model = "gpt-5" | ||
| 246 | + | case "openai-codex": | |||
| 247 | + | model = "gpt-5.5" | |||
| 239 | case "kimi": | 248 | case "kimi": | ||
| 240 | model = "kimi-for-coding" | 249 | model = "kimi-for-coding" | ||
| 241 | case "deepseek": | 250 | case "deepseek": | ||
| @@ -521,11 +530,10 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 521 | case "deepseek": | 530 | case "deepseek": | ||
| 522 | return provider.NewDeepSeek(r.Credential, r.BaseURL) | 531 | return provider.NewDeepSeek(r.Credential, r.BaseURL) | ||
| 523 | case "openai": | 532 | case "openai": | ||
| 524 | - | if r.AuthMethod == "oauth" { | |||
| 525 | - | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | |||
| 526 | - | return r.wrapWithRefresh(inner) | |||
| 527 | - | } | |||
| 528 | return provider.NewOpenAI(r.Credential, r.BaseURL) | 533 | return provider.NewOpenAI(r.Credential, r.BaseURL) | ||
| 534 | + | case "openai-codex": | |||
| 535 | + | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | |||
| 536 | + | return r.wrapWithRefresh(inner) | |||
| 529 | case "google": | 537 | case "google": | ||
| 530 | // API-key only path. Gemini Generative Language API. | 538 | // API-key only path. Gemini Generative Language API. | ||
| 531 | return provider.NewGemini(r.Credential, r.BaseURL) | 539 | return provider.NewGemini(r.Credential, r.BaseURL) | ||
| @@ -543,11 +551,15 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 543 | // sessions (hours) silently fail when the 1-hour token expires. | 551 | // sessions (hours) silently fail when the 1-hour token expires. | ||
| 544 | func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | 552 | func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | ||
| 545 | provName := r.Provider | 553 | provName := r.Provider | ||
| 554 | + | tokenProvider := provName | |||
| 555 | + | if provName == "openai-codex" { | |||
| 556 | + | tokenProvider = "openai" | |||
| 557 | + | } | |||
| 546 | baseURL := r.BaseURL | 558 | baseURL := r.BaseURL | ||
| 547 | accountID := r.AccountID | 559 | accountID := r.AccountID | ||
| 548 | 560 | ||||
| 549 | refreshFn := func(ctx context.Context) (string, error) { | 561 | refreshFn := func(ctx context.Context) (string, error) { | ||
| 550 | - | tok, err := refreshIfExpired(provName, loadOAuthToken(provName)) | 562 | + | tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider)) |
| 551 | if err != nil { | 563 | if err != nil { | ||
| 552 | return "", err | 564 | return "", err | ||
| 553 | } | 565 | } | ||
| @@ -556,7 +568,7 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { | |||||
| 556 | 568 | ||||
| 557 | factory := func(token string) provider.Client { | 569 | factory := func(token string) provider.Client { | ||
| 558 | switch provName { | 570 | switch provName { | ||
| 559 | - | case "openai": | 571 | + | case "openai-codex": |
| 560 | return provider.NewOpenAICodex(token, accountID, baseURL) | 572 | return provider.NewOpenAICodex(token, accountID, baseURL) | ||
| 561 | case "kimi": | 573 | case "kimi": | ||
| 562 | return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders()) | 574 | return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders()) | ||
| @@ -671,7 +683,7 @@ func kimiCodeHeaders() map[string]string { | |||||
| 671 | 683 | ||||
| 672 | func envVarName(provider string) string { | 684 | func envVarName(provider string) string { | ||
| 673 | switch provider { | 685 | switch provider { | ||
| 674 | - | case "openai": | 686 | + | case "openai", "openai-codex": |
| 675 | return "OPENAI" | 687 | return "OPENAI" | ||
| 676 | case "kimi": | 688 | case "kimi": | ||
| 677 | return "KIMI" | 689 | return "KIMI" | ||
M internal/agent/cli.go +1 -1
| @@ -902,7 +902,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 902 | 902 | BuildAgentForRescue: buildAgentForRescue, | |
| 903 | 903 | LoggedInProviders: func() []string { | |
| 904 | 904 | var out []string | |
| 905 | - | for _, p := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { | |
| 905 | + | for _, p := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} { | |
| 906 | 906 | if _, _, err := ResolveCredential(p, ""); err == nil { | |
| 907 | 907 | out = append(out, p) | |
| 908 | 908 | } | |
| @@ -902,7 +902,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 902 | BuildAgentForRescue: buildAgentForRescue, | 902 | BuildAgentForRescue: buildAgentForRescue, | ||
| 903 | LoggedInProviders: func() []string { | 903 | LoggedInProviders: func() []string { | ||
| 904 | var out []string | 904 | var out []string | ||
| 905 | - | for _, p := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { | 905 | + | for _, p := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} { |
| 906 | if _, _, err := ResolveCredential(p, ""); err == nil { | 906 | if _, _, err := ResolveCredential(p, ""); err == nil { | ||
| 907 | out = append(out, p) | 907 | out = append(out, p) | ||
| 908 | } | 908 | } | ||
M internal/agent/config.go +5 -0
| @@ -141,6 +141,10 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 141 | 141 | if v := os.Getenv("OPENAI_API_KEY"); v != "" { | |
| 142 | 142 | return v, "apikey", "", nil | |
| 143 | 143 | } | |
| 144 | + | case "openai-codex": | |
| 145 | + | // ChatGPT/Codex subscription route. It intentionally ignores | |
| 146 | + | // OPENAI_API_KEY so users can keep both OpenAI API and Codex | |
| 147 | + | // subscription credentials configured and choose by provider. | |
| 144 | 148 | case "kimi": | |
| 145 | 149 | if v := os.Getenv("KIMI_API_KEY"); v != "" { | |
| 146 | 150 | return v, "apikey", "", nil | |
| @@ -180,6 +184,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 180 | 184 | if c.OpenAI.APIKey != "" { | |
| 181 | 185 | return c.OpenAI.APIKey, "apikey", "", nil | |
| 182 | 186 | } | |
| 187 | + | case "openai-codex": | |
| 183 | 188 | if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { | |
| 184 | 189 | tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) | |
| 185 | 190 | return tok.AccessToken, "oauth", tok.AccountID, nil | |
| @@ -141,6 +141,10 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||||
| 141 | if v := os.Getenv("OPENAI_API_KEY"); v != "" { | 141 | if v := os.Getenv("OPENAI_API_KEY"); v != "" { | ||
| 142 | return v, "apikey", "", nil | 142 | return v, "apikey", "", nil | ||
| 143 | } | 143 | } | ||
| 144 | + | case "openai-codex": | |||
| 145 | + | // ChatGPT/Codex subscription route. It intentionally ignores | |||
| 146 | + | // OPENAI_API_KEY so users can keep both OpenAI API and Codex | |||
| 147 | + | // subscription credentials configured and choose by provider. | |||
| 144 | case "kimi": | 148 | case "kimi": | ||
| 145 | if v := os.Getenv("KIMI_API_KEY"); v != "" { | 149 | if v := os.Getenv("KIMI_API_KEY"); v != "" { | ||
| 146 | return v, "apikey", "", nil | 150 | return v, "apikey", "", nil | ||
| @@ -180,6 +184,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||||
| 180 | if c.OpenAI.APIKey != "" { | 184 | if c.OpenAI.APIKey != "" { | ||
| 181 | return c.OpenAI.APIKey, "apikey", "", nil | 185 | return c.OpenAI.APIKey, "apikey", "", nil | ||
| 182 | } | 186 | } | ||
| 187 | + | case "openai-codex": | |||
| 183 | if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { | 188 | if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { | ||
| 184 | tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) | 189 | tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) | ||
| 185 | return tok.AccessToken, "oauth", tok.AccountID, nil | 190 | return tok.AccessToken, "oauth", tok.AccountID, nil | ||
M internal/agent/modes/interactive.go +20 -5
| @@ -2907,7 +2907,7 @@ func (i *Interactive) openLogoutDialog() { | |||
| 2907 | 2907 | } | |
| 2908 | 2908 | ||
| 2909 | 2909 | var items []logoutItem | |
| 2910 | - | for _, p := range []string{"anthropic", "openai", "kimi", "google"} { | |
| 2910 | + | for _, p := range []string{"anthropic", "kimi", "google"} { | |
| 2911 | 2911 | if creds.Has(p) { | |
| 2912 | 2912 | method := creds.Method(p) | |
| 2913 | 2913 | if method == "oauth" { | |
| @@ -2920,6 +2920,12 @@ func (i *Interactive) openLogoutDialog() { | |||
| 2920 | 2920 | }) | |
| 2921 | 2921 | } | |
| 2922 | 2922 | } | |
| 2923 | + | if creds.OpenAI.APIKey != "" { | |
| 2924 | + | items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"}) | |
| 2925 | + | } | |
| 2926 | + | if creds.OpenAI.OAuth != nil { | |
| 2927 | + | items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"}) | |
| 2928 | + | } | |
| 2923 | 2929 | if len(items) == 0 { | |
| 2924 | 2930 | i.mu.Lock() | |
| 2925 | 2931 | i.statusOK = "no credentials stored; already logged out" | |
| @@ -2960,12 +2966,12 @@ func (i *Interactive) doLogout(target string) { | |||
| 2960 | 2966 | var providers []string | |
| 2961 | 2967 | switch target { | |
| 2962 | 2968 | case "", "all": | |
| 2963 | - | providers = []string{"anthropic", "openai", "kimi", "google"} | |
| 2964 | - | case "anthropic", "openai", "kimi", "google": | |
| 2969 | + | providers = []string{"anthropic", "openai", "openai-codex", "kimi", "google"} | |
| 2970 | + | case "anthropic", "openai", "openai-codex", "kimi", "google": | |
| 2965 | 2971 | providers = []string{target} | |
| 2966 | 2972 | default: | |
| 2967 | 2973 | i.mu.Lock() | |
| 2968 | - | i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, google, or all)" | |
| 2974 | + | i.statusErr = "unknown provider: " + target + " (use anthropic, openai, openai-codex, kimi, google, or all)" | |
| 2969 | 2975 | i.mu.Unlock() | |
| 2970 | 2976 | return | |
| 2971 | 2977 | } | |
| @@ -2973,7 +2979,16 @@ func (i *Interactive) doLogout(target string) { | |||
| 2973 | 2979 | var errs []string | |
| 2974 | 2980 | clearedCurrent := false | |
| 2975 | 2981 | for _, p := range providers { | |
| 2976 | - | if err := store.Clear(p); err != nil { | |
| 2982 | + | var err error | |
| 2983 | + | switch p { | |
| 2984 | + | case "openai": | |
| 2985 | + | err = store.ClearAPIKey("openai") | |
| 2986 | + | case "openai-codex": | |
| 2987 | + | err = store.ClearOAuth("openai") | |
| 2988 | + | default: | |
| 2989 | + | err = store.Clear(p) | |
| 2990 | + | } | |
| 2991 | + | if err != nil { | |
| 2977 | 2992 | errs = append(errs, p+": "+err.Error()) | |
| 2978 | 2993 | continue | |
| 2979 | 2994 | } | |
| @@ -2907,7 +2907,7 @@ func (i *Interactive) openLogoutDialog() { | |||||
| 2907 | } | 2907 | } | ||
| 2908 | 2908 | ||||
| 2909 | var items []logoutItem | 2909 | var items []logoutItem | ||
| 2910 | - | for _, p := range []string{"anthropic", "openai", "kimi", "google"} { | 2910 | + | for _, p := range []string{"anthropic", "kimi", "google"} { |
| 2911 | if creds.Has(p) { | 2911 | if creds.Has(p) { | ||
| 2912 | method := creds.Method(p) | 2912 | method := creds.Method(p) | ||
| 2913 | if method == "oauth" { | 2913 | if method == "oauth" { | ||
| @@ -2920,6 +2920,12 @@ func (i *Interactive) openLogoutDialog() { | |||||
| 2920 | }) | 2920 | }) | ||
| 2921 | } | 2921 | } | ||
| 2922 | } | 2922 | } | ||
| 2923 | + | if creds.OpenAI.APIKey != "" { | |||
| 2924 | + | items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"}) | |||
| 2925 | + | } | |||
| 2926 | + | if creds.OpenAI.OAuth != nil { | |||
| 2927 | + | items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"}) | |||
| 2928 | + | } | |||
| 2923 | if len(items) == 0 { | 2929 | if len(items) == 0 { | ||
| 2924 | i.mu.Lock() | 2930 | i.mu.Lock() | ||
| 2925 | i.statusOK = "no credentials stored; already logged out" | 2931 | i.statusOK = "no credentials stored; already logged out" | ||
| @@ -2960,12 +2966,12 @@ func (i *Interactive) doLogout(target string) { | |||||
| 2960 | var providers []string | 2966 | var providers []string | ||
| 2961 | switch target { | 2967 | switch target { | ||
| 2962 | case "", "all": | 2968 | case "", "all": | ||
| 2963 | - | providers = []string{"anthropic", "openai", "kimi", "google"} | 2969 | + | providers = []string{"anthropic", "openai", "openai-codex", "kimi", "google"} |
| 2964 | - | case "anthropic", "openai", "kimi", "google": | 2970 | + | case "anthropic", "openai", "openai-codex", "kimi", "google": |
| 2965 | providers = []string{target} | 2971 | providers = []string{target} | ||
| 2966 | default: | 2972 | default: | ||
| 2967 | i.mu.Lock() | 2973 | i.mu.Lock() | ||
| 2968 | - | i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, google, or all)" | 2974 | + | i.statusErr = "unknown provider: " + target + " (use anthropic, openai, openai-codex, kimi, google, or all)" |
| 2969 | i.mu.Unlock() | 2975 | i.mu.Unlock() | ||
| 2970 | return | 2976 | return | ||
| 2971 | } | 2977 | } | ||
| @@ -2973,7 +2979,16 @@ func (i *Interactive) doLogout(target string) { | |||||
| 2973 | var errs []string | 2979 | var errs []string | ||
| 2974 | clearedCurrent := false | 2980 | clearedCurrent := false | ||
| 2975 | for _, p := range providers { | 2981 | for _, p := range providers { | ||
| 2976 | - | if err := store.Clear(p); err != nil { | 2982 | + | var err error |
| 2983 | + | switch p { | |||
| 2984 | + | case "openai": | |||
| 2985 | + | err = store.ClearAPIKey("openai") | |||
| 2986 | + | case "openai-codex": | |||
| 2987 | + | err = store.ClearOAuth("openai") | |||
| 2988 | + | default: | |||
| 2989 | + | err = store.Clear(p) | |||
| 2990 | + | } | |||
| 2991 | + | if err != nil { | |||
| 2977 | errs = append(errs, p+": "+err.Error()) | 2992 | errs = append(errs, p+": "+err.Error()) | ||
| 2978 | continue | 2993 | continue | ||
| 2979 | } | 2994 | } | ||
M internal/agent/modes/login_dialog.go +19 -8
| @@ -28,7 +28,7 @@ const ( | |||
| 28 | 28 | type loginDialog struct { | |
| 29 | 29 | step loginStep | |
| 30 | 30 | method string // "apikey" | "oauth" | |
| 31 | - | provider string // "anthropic" | "openai" | "kimi" | "google" | |
| 31 | + | provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google" | |
| 32 | 32 | message string | |
| 33 | 33 | success bool | |
| 34 | 34 | url string | |
| @@ -39,8 +39,8 @@ type loginDialog struct { | |||
| 39 | 39 | // provider, captured when Open() runs. Rendered above the | |
| 40 | 40 | // method picker so the user can see whether they're already | |
| 41 | 41 | // logged in (and how) before starting a new flow. Keys: | |
| 42 | - | // "anthropic", "openai", "kimi", "google". Value is "apikey", "oauth", or "" | |
| 43 | - | // (not logged in). | |
| 42 | + | // "anthropic", "openai", "openai-codex", "kimi", "google". Value is | |
| 43 | + | // "apikey", "oauth", or "" (not logged in). | |
| 44 | 44 | status map[string]string | |
| 45 | 45 | } | |
| 46 | 46 | ||
| @@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) { | |||
| 65 | 65 | d.success = false | |
| 66 | 66 | d.url = "" | |
| 67 | 67 | d.cursor = 0 | |
| 68 | - | d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "deepseek": "", "google": ""} | |
| 68 | + | d.status = map[string]string{"anthropic": "", "openai": "", "openai-codex": "", "kimi": "", "deepseek": "", "google": ""} | |
| 69 | 69 | // Best-effort: if the auth file can't be read, treat every | |
| 70 | 70 | // provider as not-logged-in. The status line just won't show | |
| 71 | 71 | // anything useful in that case, which is fine — the user | |
| @@ -73,7 +73,14 @@ func (d *loginDialog) Open(zotHome string) { | |||
| 73 | 73 | path := filepath.Join(zotHome, "auth.json") | |
| 74 | 74 | if creds, err := auth.NewStore(path).Load(); err == nil { | |
| 75 | 75 | d.status["anthropic"] = creds.Method("anthropic") | |
| 76 | - | d.status["openai"] = creds.Method("openai") | |
| 76 | + | d.status["openai"] = "" | |
| 77 | + | if creds.OpenAI.APIKey != "" { | |
| 78 | + | d.status["openai"] = "apikey" | |
| 79 | + | } | |
| 80 | + | d.status["openai-codex"] = "" | |
| 81 | + | if creds.OpenAI.OAuth != nil { | |
| 82 | + | d.status["openai-codex"] = "oauth" | |
| 83 | + | } | |
| 77 | 84 | d.status["kimi"] = creds.Method("kimi") | |
| 78 | 85 | d.status["deepseek"] = creds.Method("deepseek") | |
| 79 | 86 | d.status["google"] = creds.Method("google") | |
| @@ -205,7 +212,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 205 | 212 | // subscription product at all). | |
| 206 | 213 | func providersForMethod(method string) []string { | |
| 207 | 214 | if method == "oauth" { | |
| 208 | - | return []string{"anthropic", "openai", "kimi"} | |
| 215 | + | return []string{"anthropic", "openai-codex", "kimi"} | |
| 209 | 216 | } | |
| 210 | 217 | return []string{"anthropic", "openai", "kimi", "deepseek", "google"} | |
| 211 | 218 | } | |
| @@ -216,7 +223,9 @@ func providerLabel(id string) string { | |||
| 216 | 223 | case "anthropic": | |
| 217 | 224 | return "Anthropic (Claude Pro/Max)" | |
| 218 | 225 | case "openai": | |
| 219 | - | return "OpenAI (ChatGPT Plus/Pro)" | |
| 226 | + | return "OpenAI" | |
| 227 | + | case "openai-codex": | |
| 228 | + | return "OpenAI Codex (ChatGPT Plus/Pro)" | |
| 220 | 229 | case "kimi": | |
| 221 | 230 | return "Kimi Code" | |
| 222 | 231 | case "deepseek": | |
| @@ -240,10 +249,11 @@ func providerLabel(id string) string { | |||
| 240 | 249 | func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |
| 241 | 250 | anth := d.status["anthropic"] | |
| 242 | 251 | op := d.status["openai"] | |
| 252 | + | codex := d.status["openai-codex"] | |
| 243 | 253 | kimi := d.status["kimi"] | |
| 244 | 254 | ds := d.status["deepseek"] | |
| 245 | 255 | goog := d.status["google"] | |
| 246 | - | if anth == "" && op == "" && kimi == "" && ds == "" && goog == "" { | |
| 256 | + | if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" { | |
| 247 | 257 | return nil | |
| 248 | 258 | } | |
| 249 | 259 | row := func(id, method string) string { | |
| @@ -265,6 +275,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||
| 265 | 275 | return []string{ | |
| 266 | 276 | row("anthropic", anth), | |
| 267 | 277 | row("openai", op), | |
| 278 | + | row("openai-codex", codex), | |
| 268 | 279 | row("kimi", kimi), | |
| 269 | 280 | row("deepseek", ds), | |
| 270 | 281 | row("google", goog), | |
| @@ -28,7 +28,7 @@ const ( | |||||
| 28 | type loginDialog struct { | 28 | type loginDialog struct { | ||
| 29 | step loginStep | 29 | step loginStep | ||
| 30 | method string // "apikey" | "oauth" | 30 | method string // "apikey" | "oauth" | ||
| 31 | - | provider string // "anthropic" | "openai" | "kimi" | "google" | 31 | + | provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google" |
| 32 | message string | 32 | message string | ||
| 33 | success bool | 33 | success bool | ||
| 34 | url string | 34 | url string | ||
| @@ -39,8 +39,8 @@ type loginDialog struct { | |||||
| 39 | // provider, captured when Open() runs. Rendered above the | 39 | // provider, captured when Open() runs. Rendered above the | ||
| 40 | // method picker so the user can see whether they're already | 40 | // method picker so the user can see whether they're already | ||
| 41 | // logged in (and how) before starting a new flow. Keys: | 41 | // logged in (and how) before starting a new flow. Keys: | ||
| 42 | - | // "anthropic", "openai", "kimi", "google". Value is "apikey", "oauth", or "" | 42 | + | // "anthropic", "openai", "openai-codex", "kimi", "google". Value is |
| 43 | - | // (not logged in). | 43 | + | // "apikey", "oauth", or "" (not logged in). |
| 44 | status map[string]string | 44 | status map[string]string | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| @@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) { | |||||
| 65 | d.success = false | 65 | d.success = false | ||
| 66 | d.url = "" | 66 | d.url = "" | ||
| 67 | d.cursor = 0 | 67 | d.cursor = 0 | ||
| 68 | - | d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "deepseek": "", "google": ""} | 68 | + | d.status = map[string]string{"anthropic": "", "openai": "", "openai-codex": "", "kimi": "", "deepseek": "", "google": ""} |
| 69 | // Best-effort: if the auth file can't be read, treat every | 69 | // Best-effort: if the auth file can't be read, treat every | ||
| 70 | // provider as not-logged-in. The status line just won't show | 70 | // provider as not-logged-in. The status line just won't show | ||
| 71 | // anything useful in that case, which is fine — the user | 71 | // anything useful in that case, which is fine — the user | ||
| @@ -73,7 +73,14 @@ func (d *loginDialog) Open(zotHome string) { | |||||
| 73 | path := filepath.Join(zotHome, "auth.json") | 73 | path := filepath.Join(zotHome, "auth.json") | ||
| 74 | if creds, err := auth.NewStore(path).Load(); err == nil { | 74 | if creds, err := auth.NewStore(path).Load(); err == nil { | ||
| 75 | d.status["anthropic"] = creds.Method("anthropic") | 75 | d.status["anthropic"] = creds.Method("anthropic") | ||
| 76 | - | d.status["openai"] = creds.Method("openai") | 76 | + | d.status["openai"] = "" |
| 77 | + | if creds.OpenAI.APIKey != "" { | |||
| 78 | + | d.status["openai"] = "apikey" | |||
| 79 | + | } | |||
| 80 | + | d.status["openai-codex"] = "" | |||
| 81 | + | if creds.OpenAI.OAuth != nil { | |||
| 82 | + | d.status["openai-codex"] = "oauth" | |||
| 83 | + | } | |||
| 77 | d.status["kimi"] = creds.Method("kimi") | 84 | d.status["kimi"] = creds.Method("kimi") | ||
| 78 | d.status["deepseek"] = creds.Method("deepseek") | 85 | d.status["deepseek"] = creds.Method("deepseek") | ||
| 79 | d.status["google"] = creds.Method("google") | 86 | d.status["google"] = creds.Method("google") | ||
| @@ -205,7 +212,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 205 | // subscription product at all). | 212 | // subscription product at all). | ||
| 206 | func providersForMethod(method string) []string { | 213 | func providersForMethod(method string) []string { | ||
| 207 | if method == "oauth" { | 214 | if method == "oauth" { | ||
| 208 | - | return []string{"anthropic", "openai", "kimi"} | 215 | + | return []string{"anthropic", "openai-codex", "kimi"} |
| 209 | } | 216 | } | ||
| 210 | return []string{"anthropic", "openai", "kimi", "deepseek", "google"} | 217 | return []string{"anthropic", "openai", "kimi", "deepseek", "google"} | ||
| 211 | } | 218 | } | ||
| @@ -216,7 +223,9 @@ func providerLabel(id string) string { | |||||
| 216 | case "anthropic": | 223 | case "anthropic": | ||
| 217 | return "Anthropic (Claude Pro/Max)" | 224 | return "Anthropic (Claude Pro/Max)" | ||
| 218 | case "openai": | 225 | case "openai": | ||
| 219 | - | return "OpenAI (ChatGPT Plus/Pro)" | 226 | + | return "OpenAI" |
| 227 | + | case "openai-codex": | |||
| 228 | + | return "OpenAI Codex (ChatGPT Plus/Pro)" | |||
| 220 | case "kimi": | 229 | case "kimi": | ||
| 221 | return "Kimi Code" | 230 | return "Kimi Code" | ||
| 222 | case "deepseek": | 231 | case "deepseek": | ||
| @@ -240,10 +249,11 @@ func providerLabel(id string) string { | |||||
| 240 | func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | 249 | func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | ||
| 241 | anth := d.status["anthropic"] | 250 | anth := d.status["anthropic"] | ||
| 242 | op := d.status["openai"] | 251 | op := d.status["openai"] | ||
| 252 | + | codex := d.status["openai-codex"] | |||
| 243 | kimi := d.status["kimi"] | 253 | kimi := d.status["kimi"] | ||
| 244 | ds := d.status["deepseek"] | 254 | ds := d.status["deepseek"] | ||
| 245 | goog := d.status["google"] | 255 | goog := d.status["google"] | ||
| 246 | - | if anth == "" && op == "" && kimi == "" && ds == "" && goog == "" { | 256 | + | if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" { |
| 247 | return nil | 257 | return nil | ||
| 248 | } | 258 | } | ||
| 249 | row := func(id, method string) string { | 259 | row := func(id, method string) string { | ||
| @@ -265,6 +275,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||||
| 265 | return []string{ | 275 | return []string{ | ||
| 266 | row("anthropic", anth), | 276 | row("anthropic", anth), | ||
| 267 | row("openai", op), | 277 | row("openai", op), | ||
| 278 | + | row("openai-codex", codex), | |||
| 268 | row("kimi", kimi), | 279 | row("kimi", kimi), | ||
| 269 | row("deepseek", ds), | 280 | row("deepseek", ds), | ||
| 270 | row("google", goog), | 281 | row("google", goog), | ||
M internal/auth/manager.go +31 -19
| @@ -34,9 +34,11 @@ type Manager struct { | |||
| 34 | 34 | oauthCtx context.Context | |
| 35 | 35 | oauthCancel context.CancelFunc | |
| 36 | 36 | ||
| 37 | - | manualOp *OAuthProvider | |
| 38 | - | manualPKCE PKCE | |
| 39 | - | manualState string | |
| 37 | + | manualOp *OAuthProvider | |
| 38 | + | manualStoreProvider string | |
| 39 | + | manualEventProvider string | |
| 40 | + | manualPKCE PKCE | |
| 41 | + | manualState string | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | 44 | // NewManager returns a Manager bound to store. | |
| @@ -133,18 +135,20 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 133 | 135 | if provider == "kimi" { | |
| 134 | 136 | return m.StartKimiDeviceOAuth() | |
| 135 | 137 | } | |
| 138 | + | storeProvider := provider | |
| 136 | 139 | var op OAuthProvider | |
| 137 | 140 | switch provider { | |
| 138 | 141 | case "anthropic": | |
| 139 | 142 | op = AnthropicOAuth | |
| 140 | - | case "openai": | |
| 143 | + | case "openai", "openai-codex": | |
| 141 | 144 | op = OpenAIOAuth | |
| 145 | + | storeProvider = "openai" | |
| 142 | 146 | case "google": | |
| 143 | 147 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | |
| 144 | 148 | case "deepseek": | |
| 145 | 149 | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | |
| 146 | 150 | default: | |
| 147 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") | |
| 151 | + | return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google") | |
| 148 | 152 | } | |
| 149 | 153 | ||
| 150 | 154 | m.mu.Lock() | |
| @@ -178,13 +182,13 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 178 | 182 | m.oauthCancel = cancel | |
| 179 | 183 | m.mu.Unlock() | |
| 180 | 184 | ||
| 181 | - | go m.awaitOAuth(ctx, op, cs, pkce, state) | |
| 185 | + | go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state) | |
| 182 | 186 | go m.maybeOpen(authURL) | |
| 183 | 187 | m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL}) | |
| 184 | 188 | return authURL, nil | |
| 185 | 189 | } | |
| 186 | 190 | ||
| 187 | - | func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *CallbackServer, pkce PKCE, state string) { | |
| 191 | + | func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvider, eventProvider string, cs *CallbackServer, pkce PKCE, state string) { | |
| 188 | 192 | defer cs.Shutdown() | |
| 189 | 193 | ||
| 190 | 194 | waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Minute) | |
| @@ -192,12 +196,12 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback | |||
| 192 | 196 | res, err := cs.Result(waitCtx) | |
| 193 | 197 | if err != nil { | |
| 194 | 198 | if ctx.Err() == nil { | |
| 195 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: "timeout waiting for callback"}) | |
| 199 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"}) | |
| 196 | 200 | } | |
| 197 | 201 | return | |
| 198 | 202 | } | |
| 199 | 203 | if res.Err != nil { | |
| 200 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: res.Err.Error()}) | |
| 204 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()}) | |
| 201 | 205 | return | |
| 202 | 206 | } | |
| 203 | 207 | ||
| @@ -205,14 +209,14 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback | |||
| 205 | 209 | defer exCancel() | |
| 206 | 210 | tok, err := op.Exchange(exCtx, res.Code, res.State, pkce) | |
| 207 | 211 | if err != nil { | |
| 208 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |
| 212 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | |
| 209 | 213 | return | |
| 210 | 214 | } | |
| 211 | - | if err := m.store.SetOAuth(op.Name, *tok); err != nil { | |
| 212 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |
| 215 | + | if err := m.store.SetOAuth(storeProvider, *tok); err != nil { | |
| 216 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | |
| 213 | 217 | return | |
| 214 | 218 | } | |
| 215 | - | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | |
| 219 | + | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) | |
| 216 | 220 | } | |
| 217 | 221 | ||
| 218 | 222 | // StartKimiDeviceOAuth starts Kimi Code's device-code subscription login. | |
| @@ -258,18 +262,20 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 258 | 262 | if provider == "kimi" { | |
| 259 | 263 | return m.StartKimiDeviceOAuth() | |
| 260 | 264 | } | |
| 265 | + | storeProvider := provider | |
| 261 | 266 | var op OAuthProvider | |
| 262 | 267 | switch provider { | |
| 263 | 268 | case "anthropic": | |
| 264 | 269 | op = AnthropicManualOAuth | |
| 265 | - | case "openai": | |
| 270 | + | case "openai", "openai-codex": | |
| 266 | 271 | op = OpenAIOAuth | |
| 272 | + | storeProvider = "openai" | |
| 267 | 273 | case "google": | |
| 268 | 274 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | |
| 269 | 275 | case "deepseek": | |
| 270 | 276 | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | |
| 271 | 277 | default: | |
| 272 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") | |
| 278 | + | return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google") | |
| 273 | 279 | } | |
| 274 | 280 | ||
| 275 | 281 | pkce, err := NewPKCE() | |
| @@ -283,6 +289,8 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 283 | 289 | ||
| 284 | 290 | m.mu.Lock() | |
| 285 | 291 | m.manualOp = &op | |
| 292 | + | m.manualStoreProvider = storeProvider | |
| 293 | + | m.manualEventProvider = provider | |
| 286 | 294 | m.manualPKCE = pkce | |
| 287 | 295 | m.manualState = state | |
| 288 | 296 | m.mu.Unlock() | |
| @@ -297,6 +305,8 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 297 | 305 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |
| 298 | 306 | m.mu.Lock() | |
| 299 | 307 | op := m.manualOp | |
| 308 | + | storeProvider := m.manualStoreProvider | |
| 309 | + | eventProvider := m.manualEventProvider | |
| 300 | 310 | pkce := m.manualPKCE | |
| 301 | 311 | state := m.manualState | |
| 302 | 312 | m.mu.Unlock() | |
| @@ -314,19 +324,21 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |||
| 314 | 324 | defer cancel() | |
| 315 | 325 | tok, err := op.Exchange(exCtx, code, state, pkce) | |
| 316 | 326 | if err != nil { | |
| 317 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |
| 327 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | |
| 318 | 328 | return err | |
| 319 | 329 | } | |
| 320 | - | if err := m.store.SetOAuth(op.Name, *tok); err != nil { | |
| 321 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |
| 330 | + | if err := m.store.SetOAuth(storeProvider, *tok); err != nil { | |
| 331 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | |
| 322 | 332 | return err | |
| 323 | 333 | } | |
| 324 | 334 | m.mu.Lock() | |
| 325 | 335 | m.manualOp = nil | |
| 336 | + | m.manualStoreProvider = "" | |
| 337 | + | m.manualEventProvider = "" | |
| 326 | 338 | m.manualPKCE = PKCE{} | |
| 327 | 339 | m.manualState = "" | |
| 328 | 340 | m.mu.Unlock() | |
| 329 | - | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | |
| 341 | + | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) | |
| 330 | 342 | return nil | |
| 331 | 343 | } | |
| 332 | 344 | ||
| @@ -34,9 +34,11 @@ type Manager struct { | |||||
| 34 | oauthCtx context.Context | 34 | oauthCtx context.Context | ||
| 35 | oauthCancel context.CancelFunc | 35 | oauthCancel context.CancelFunc | ||
| 36 | 36 | ||||
| 37 | - | manualOp *OAuthProvider | 37 | + | manualOp *OAuthProvider |
| 38 | - | manualPKCE PKCE | 38 | + | manualStoreProvider string |
| 39 | - | manualState string | 39 | + | manualEventProvider string |
| 40 | + | manualPKCE PKCE | |||
| 41 | + | manualState string | |||
| 40 | } | 42 | } | ||
| 41 | 43 | ||||
| 42 | // NewManager returns a Manager bound to store. | 44 | // NewManager returns a Manager bound to store. | ||
| @@ -133,18 +135,20 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||||
| 133 | if provider == "kimi" { | 135 | if provider == "kimi" { | ||
| 134 | return m.StartKimiDeviceOAuth() | 136 | return m.StartKimiDeviceOAuth() | ||
| 135 | } | 137 | } | ||
| 138 | + | storeProvider := provider | |||
| 136 | var op OAuthProvider | 139 | var op OAuthProvider | ||
| 137 | switch provider { | 140 | switch provider { | ||
| 138 | case "anthropic": | 141 | case "anthropic": | ||
| 139 | op = AnthropicOAuth | 142 | op = AnthropicOAuth | ||
| 140 | - | case "openai": | 143 | + | case "openai", "openai-codex": |
| 141 | op = OpenAIOAuth | 144 | op = OpenAIOAuth | ||
| 145 | + | storeProvider = "openai" | |||
| 142 | case "google": | 146 | case "google": | ||
| 143 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | 147 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | ||
| 144 | case "deepseek": | 148 | case "deepseek": | ||
| 145 | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | 149 | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | ||
| 146 | default: | 150 | default: | ||
| 147 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") | 151 | + | return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google") |
| 148 | } | 152 | } | ||
| 149 | 153 | ||||
| 150 | m.mu.Lock() | 154 | m.mu.Lock() | ||
| @@ -178,13 +182,13 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||||
| 178 | m.oauthCancel = cancel | 182 | m.oauthCancel = cancel | ||
| 179 | m.mu.Unlock() | 183 | m.mu.Unlock() | ||
| 180 | 184 | ||||
| 181 | - | go m.awaitOAuth(ctx, op, cs, pkce, state) | 185 | + | go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state) |
| 182 | go m.maybeOpen(authURL) | 186 | go m.maybeOpen(authURL) | ||
| 183 | m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL}) | 187 | m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL}) | ||
| 184 | return authURL, nil | 188 | return authURL, nil | ||
| 185 | } | 189 | } | ||
| 186 | 190 | ||||
| 187 | - | func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *CallbackServer, pkce PKCE, state string) { | 191 | + | func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvider, eventProvider string, cs *CallbackServer, pkce PKCE, state string) { |
| 188 | defer cs.Shutdown() | 192 | defer cs.Shutdown() | ||
| 189 | 193 | ||||
| 190 | waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Minute) | 194 | waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Minute) | ||
| @@ -192,12 +196,12 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback | |||||
| 192 | res, err := cs.Result(waitCtx) | 196 | res, err := cs.Result(waitCtx) | ||
| 193 | if err != nil { | 197 | if err != nil { | ||
| 194 | if ctx.Err() == nil { | 198 | if ctx.Err() == nil { | ||
| 195 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: "timeout waiting for callback"}) | 199 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"}) |
| 196 | } | 200 | } | ||
| 197 | return | 201 | return | ||
| 198 | } | 202 | } | ||
| 199 | if res.Err != nil { | 203 | if res.Err != nil { | ||
| 200 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: res.Err.Error()}) | 204 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()}) |
| 201 | return | 205 | return | ||
| 202 | } | 206 | } | ||
| 203 | 207 | ||||
| @@ -205,14 +209,14 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback | |||||
| 205 | defer exCancel() | 209 | defer exCancel() | ||
| 206 | tok, err := op.Exchange(exCtx, res.Code, res.State, pkce) | 210 | tok, err := op.Exchange(exCtx, res.Code, res.State, pkce) | ||
| 207 | if err != nil { | 211 | if err != nil { | ||
| 208 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | 212 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) |
| 209 | return | 213 | return | ||
| 210 | } | 214 | } | ||
| 211 | - | if err := m.store.SetOAuth(op.Name, *tok); err != nil { | 215 | + | if err := m.store.SetOAuth(storeProvider, *tok); err != nil { |
| 212 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | 216 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) |
| 213 | return | 217 | return | ||
| 214 | } | 218 | } | ||
| 215 | - | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | 219 | + | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) |
| 216 | } | 220 | } | ||
| 217 | 221 | ||||
| 218 | // StartKimiDeviceOAuth starts Kimi Code's device-code subscription login. | 222 | // StartKimiDeviceOAuth starts Kimi Code's device-code subscription login. | ||
| @@ -258,18 +262,20 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 258 | if provider == "kimi" { | 262 | if provider == "kimi" { | ||
| 259 | return m.StartKimiDeviceOAuth() | 263 | return m.StartKimiDeviceOAuth() | ||
| 260 | } | 264 | } | ||
| 265 | + | storeProvider := provider | |||
| 261 | var op OAuthProvider | 266 | var op OAuthProvider | ||
| 262 | switch provider { | 267 | switch provider { | ||
| 263 | case "anthropic": | 268 | case "anthropic": | ||
| 264 | op = AnthropicManualOAuth | 269 | op = AnthropicManualOAuth | ||
| 265 | - | case "openai": | 270 | + | case "openai", "openai-codex": |
| 266 | op = OpenAIOAuth | 271 | op = OpenAIOAuth | ||
| 272 | + | storeProvider = "openai" | |||
| 267 | case "google": | 273 | case "google": | ||
| 268 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | 274 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | ||
| 269 | case "deepseek": | 275 | case "deepseek": | ||
| 270 | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | 276 | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | ||
| 271 | default: | 277 | default: | ||
| 272 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") | 278 | + | return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, deepseek, or google") |
| 273 | } | 279 | } | ||
| 274 | 280 | ||||
| 275 | pkce, err := NewPKCE() | 281 | pkce, err := NewPKCE() | ||
| @@ -283,6 +289,8 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 283 | 289 | ||||
| 284 | m.mu.Lock() | 290 | m.mu.Lock() | ||
| 285 | m.manualOp = &op | 291 | m.manualOp = &op | ||
| 292 | + | m.manualStoreProvider = storeProvider | |||
| 293 | + | m.manualEventProvider = provider | |||
| 286 | m.manualPKCE = pkce | 294 | m.manualPKCE = pkce | ||
| 287 | m.manualState = state | 295 | m.manualState = state | ||
| 288 | m.mu.Unlock() | 296 | m.mu.Unlock() | ||
| @@ -297,6 +305,8 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 297 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | 305 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | ||
| 298 | m.mu.Lock() | 306 | m.mu.Lock() | ||
| 299 | op := m.manualOp | 307 | op := m.manualOp | ||
| 308 | + | storeProvider := m.manualStoreProvider | |||
| 309 | + | eventProvider := m.manualEventProvider | |||
| 300 | pkce := m.manualPKCE | 310 | pkce := m.manualPKCE | ||
| 301 | state := m.manualState | 311 | state := m.manualState | ||
| 302 | m.mu.Unlock() | 312 | m.mu.Unlock() | ||
| @@ -314,19 +324,21 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |||||
| 314 | defer cancel() | 324 | defer cancel() | ||
| 315 | tok, err := op.Exchange(exCtx, code, state, pkce) | 325 | tok, err := op.Exchange(exCtx, code, state, pkce) | ||
| 316 | if err != nil { | 326 | if err != nil { | ||
| 317 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | 327 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) |
| 318 | return err | 328 | return err | ||
| 319 | } | 329 | } | ||
| 320 | - | if err := m.store.SetOAuth(op.Name, *tok); err != nil { | 330 | + | if err := m.store.SetOAuth(storeProvider, *tok); err != nil { |
| 321 | - | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | 331 | + | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) |
| 322 | return err | 332 | return err | ||
| 323 | } | 333 | } | ||
| 324 | m.mu.Lock() | 334 | m.mu.Lock() | ||
| 325 | m.manualOp = nil | 335 | m.manualOp = nil | ||
| 336 | + | m.manualStoreProvider = "" | |||
| 337 | + | m.manualEventProvider = "" | |||
| 326 | m.manualPKCE = PKCE{} | 338 | m.manualPKCE = PKCE{} | ||
| 327 | m.manualState = "" | 339 | m.manualState = "" | ||
| 328 | m.mu.Unlock() | 340 | m.mu.Unlock() | ||
| 329 | - | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | 341 | + | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) |
| 330 | return nil | 342 | return nil | ||
| 331 | } | 343 | } | ||
| 332 | 344 | ||||
M internal/auth/store.go +41 -4
| @@ -23,8 +23,9 @@ type Credentials struct { | |||
| 23 | 23 | DeepSeek ProviderCreds `json:"deepseek,omitempty"` | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | // ProviderCreds holds credentials for a single provider. Only one of | |
| 27 | - | // APIKey or OAuth is populated at a time. | |
| 26 | + | // ProviderCreds holds credentials for a single provider. Most providers | |
| 27 | + | // use either APIKey or OAuth; OpenAI may store both so the public API | |
| 28 | + | // route and ChatGPT/Codex subscription route can coexist. | |
| 28 | 29 | type ProviderCreds struct { | |
| 29 | 30 | APIKey string `json:"api_key,omitempty"` | |
| 30 | 31 | OAuth *OAuthToken `json:"oauth,omitempty"` | |
| @@ -138,7 +139,9 @@ func (s *Store) SetAPIKey(provider, key string) error { | |||
| 138 | 139 | return fmt.Errorf("unknown provider %q", provider) | |
| 139 | 140 | } | |
| 140 | 141 | p.APIKey = key | |
| 141 | - | p.OAuth = nil | |
| 142 | + | if provider != "openai" { | |
| 143 | + | p.OAuth = nil | |
| 144 | + | } | |
| 142 | 145 | return s.saveLocked(c) | |
| 143 | 146 | } | |
| 144 | 147 | ||
| @@ -154,7 +157,9 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error { | |||
| 154 | 157 | if p == nil { | |
| 155 | 158 | return fmt.Errorf("unknown provider %q", provider) | |
| 156 | 159 | } | |
| 157 | - | p.APIKey = "" | |
| 160 | + | if provider != "openai" { | |
| 161 | + | p.APIKey = "" | |
| 162 | + | } | |
| 158 | 163 | p.OAuth = &tok | |
| 159 | 164 | return s.saveLocked(c) | |
| 160 | 165 | } | |
| @@ -175,6 +180,38 @@ func (s *Store) Clear(provider string) error { | |||
| 175 | 180 | return s.saveLocked(c) | |
| 176 | 181 | } | |
| 177 | 182 | ||
| 183 | + | // ClearAPIKey removes only the API key for provider, preserving any OAuth token. | |
| 184 | + | func (s *Store) ClearAPIKey(provider string) error { | |
| 185 | + | s.mu.Lock() | |
| 186 | + | defer s.mu.Unlock() | |
| 187 | + | c, err := s.loadLocked() | |
| 188 | + | if err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | p := c.get(provider) | |
| 192 | + | if p == nil { | |
| 193 | + | return fmt.Errorf("unknown provider %q", provider) | |
| 194 | + | } | |
| 195 | + | p.APIKey = "" | |
| 196 | + | return s.saveLocked(c) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // ClearOAuth removes only the OAuth token for provider, preserving any API key. | |
| 200 | + | func (s *Store) ClearOAuth(provider string) error { | |
| 201 | + | s.mu.Lock() | |
| 202 | + | defer s.mu.Unlock() | |
| 203 | + | c, err := s.loadLocked() | |
| 204 | + | if err != nil { | |
| 205 | + | return err | |
| 206 | + | } | |
| 207 | + | p := c.get(provider) | |
| 208 | + | if p == nil { | |
| 209 | + | return fmt.Errorf("unknown provider %q", provider) | |
| 210 | + | } | |
| 211 | + | p.OAuth = nil | |
| 212 | + | return s.saveLocked(c) | |
| 213 | + | } | |
| 214 | + | ||
| 178 | 215 | func (s *Store) saveLocked(c Credentials) error { | |
| 179 | 216 | if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { | |
| 180 | 217 | return err | |
| @@ -23,8 +23,9 @@ type Credentials struct { | |||||
| 23 | DeepSeek ProviderCreds `json:"deepseek,omitempty"` | 23 | DeepSeek ProviderCreds `json:"deepseek,omitempty"` | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | - | // ProviderCreds holds credentials for a single provider. Only one of | 26 | + | // ProviderCreds holds credentials for a single provider. Most providers |
| 27 | - | // APIKey or OAuth is populated at a time. | 27 | + | // use either APIKey or OAuth; OpenAI may store both so the public API |
| 28 | + | // route and ChatGPT/Codex subscription route can coexist. | |||
| 28 | type ProviderCreds struct { | 29 | type ProviderCreds struct { | ||
| 29 | APIKey string `json:"api_key,omitempty"` | 30 | APIKey string `json:"api_key,omitempty"` | ||
| 30 | OAuth *OAuthToken `json:"oauth,omitempty"` | 31 | OAuth *OAuthToken `json:"oauth,omitempty"` | ||
| @@ -138,7 +139,9 @@ func (s *Store) SetAPIKey(provider, key string) error { | |||||
| 138 | return fmt.Errorf("unknown provider %q", provider) | 139 | return fmt.Errorf("unknown provider %q", provider) | ||
| 139 | } | 140 | } | ||
| 140 | p.APIKey = key | 141 | p.APIKey = key | ||
| 141 | - | p.OAuth = nil | 142 | + | if provider != "openai" { |
| 143 | + | p.OAuth = nil | |||
| 144 | + | } | |||
| 142 | return s.saveLocked(c) | 145 | return s.saveLocked(c) | ||
| 143 | } | 146 | } | ||
| 144 | 147 | ||||
| @@ -154,7 +157,9 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error { | |||||
| 154 | if p == nil { | 157 | if p == nil { | ||
| 155 | return fmt.Errorf("unknown provider %q", provider) | 158 | return fmt.Errorf("unknown provider %q", provider) | ||
| 156 | } | 159 | } | ||
| 157 | - | p.APIKey = "" | 160 | + | if provider != "openai" { |
| 161 | + | p.APIKey = "" | |||
| 162 | + | } | |||
| 158 | p.OAuth = &tok | 163 | p.OAuth = &tok | ||
| 159 | return s.saveLocked(c) | 164 | return s.saveLocked(c) | ||
| 160 | } | 165 | } | ||
| @@ -175,6 +180,38 @@ func (s *Store) Clear(provider string) error { | |||||
| 175 | return s.saveLocked(c) | 180 | return s.saveLocked(c) | ||
| 176 | } | 181 | } | ||
| 177 | 182 | ||||
| 183 | + | // ClearAPIKey removes only the API key for provider, preserving any OAuth token. | |||
| 184 | + | func (s *Store) ClearAPIKey(provider string) error { | |||
| 185 | + | s.mu.Lock() | |||
| 186 | + | defer s.mu.Unlock() | |||
| 187 | + | c, err := s.loadLocked() | |||
| 188 | + | if err != nil { | |||
| 189 | + | return err | |||
| 190 | + | } | |||
| 191 | + | p := c.get(provider) | |||
| 192 | + | if p == nil { | |||
| 193 | + | return fmt.Errorf("unknown provider %q", provider) | |||
| 194 | + | } | |||
| 195 | + | p.APIKey = "" | |||
| 196 | + | return s.saveLocked(c) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // ClearOAuth removes only the OAuth token for provider, preserving any API key. | |||
| 200 | + | func (s *Store) ClearOAuth(provider string) error { | |||
| 201 | + | s.mu.Lock() | |||
| 202 | + | defer s.mu.Unlock() | |||
| 203 | + | c, err := s.loadLocked() | |||
| 204 | + | if err != nil { | |||
| 205 | + | return err | |||
| 206 | + | } | |||
| 207 | + | p := c.get(provider) | |||
| 208 | + | if p == nil { | |||
| 209 | + | return fmt.Errorf("unknown provider %q", provider) | |||
| 210 | + | } | |||
| 211 | + | p.OAuth = nil | |||
| 212 | + | return s.saveLocked(c) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 178 | func (s *Store) saveLocked(c Credentials) error { | 215 | func (s *Store) saveLocked(c Credentials) error { | ||
| 179 | if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { | 216 | if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { | ||
| 180 | return err | 217 | return err | ||
M internal/provider/models.go +49 -12
| @@ -245,12 +245,8 @@ var Catalog = []Model{ | |||
| 245 | 245 | }, | |
| 246 | 246 | ||
| 247 | 247 | // ---- Speculative: OpenAI ---- | |
| 248 | - | // Context windows on the OpenAI gpt-5.x family differ by route: | |
| 249 | - | // the direct API advertises 400k, the ChatGPT Codex OAuth backend | |
| 250 | - | // caps at 272k. zot serves both auth modes from one catalog row | |
| 251 | - | // per id, so we pin to the smaller number to keep the context-usage | |
| 252 | - | // meter honest under subscription auth. Users on the direct API | |
| 253 | - | // simply see a conservative headroom estimate. | |
| 248 | + | // Public OpenAI API route. The ChatGPT/Codex subscription route is | |
| 249 | + | // represented separately below as provider "openai-codex". | |
| 254 | 250 | { | |
| 255 | 251 | Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1", | |
| 256 | 252 | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| @@ -279,12 +275,7 @@ var Catalog = []Model{ | |||
| 279 | 275 | }, | |
| 280 | 276 | { | |
| 281 | 277 | Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini", | |
| 282 | - | // ContextWindow: 400k on the OpenAI direct API, 272k on the | |
| 283 | - | // ChatGPT Codex OAuth backend. We pin to the smaller Codex | |
| 284 | - | // cap so the context-usage meter is honest under subscription | |
| 285 | - | // auth; direct-API users simply see a conservative headroom | |
| 286 | - | // estimate rather than an inflated one. | |
| 287 | - | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 278 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, | |
| 288 | 279 | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | |
| 289 | 280 | Speculative: true, | |
| 290 | 281 | }, | |
| @@ -300,6 +291,52 @@ var Catalog = []Model{ | |||
| 300 | 291 | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | |
| 301 | 292 | Speculative: true, | |
| 302 | 293 | }, | |
| 294 | + | ||
| 295 | + | // ---- OpenAI Codex / ChatGPT subscription backend ---- | |
| 296 | + | // Same model ids as the OpenAI family, but routed through the | |
| 297 | + | // ChatGPT Codex OAuth backend rather than api.openai.com. | |
| 298 | + | { | |
| 299 | + | Provider: "openai-codex", ID: "gpt-5.2", DisplayName: "GPT-5.2 Codex", | |
| 300 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 301 | + | PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, | |
| 302 | + | Speculative: true, | |
| 303 | + | }, | |
| 304 | + | { | |
| 305 | + | Provider: "openai-codex", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3 Codex", | |
| 306 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 307 | + | PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, | |
| 308 | + | Speculative: true, | |
| 309 | + | }, | |
| 310 | + | { | |
| 311 | + | Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark", | |
| 312 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 313 | + | PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, | |
| 314 | + | Speculative: true, | |
| 315 | + | }, | |
| 316 | + | { | |
| 317 | + | Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4 Codex", | |
| 318 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 319 | + | PriceInput: 2.50, PriceOutput: 15.00, PriceCacheRead: 0.25, | |
| 320 | + | Speculative: true, | |
| 321 | + | }, | |
| 322 | + | { | |
| 323 | + | Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini Codex", | |
| 324 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 325 | + | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | |
| 326 | + | Speculative: true, | |
| 327 | + | }, | |
| 328 | + | { | |
| 329 | + | Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5 Codex", | |
| 330 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 331 | + | PriceInput: 2.50, PriceOutput: 15.00, PriceCacheRead: 0.25, | |
| 332 | + | Speculative: true, | |
| 333 | + | }, | |
| 334 | + | { | |
| 335 | + | Provider: "openai-codex", ID: "gpt-5.5-mini", DisplayName: "GPT-5.5 mini Codex", | |
| 336 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 337 | + | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | |
| 338 | + | Speculative: true, | |
| 339 | + | }, | |
| 303 | 340 | } | |
| 304 | 341 | ||
| 305 | 342 | // DefaultModel is used when the user does not specify one. | |
| @@ -245,12 +245,8 @@ var Catalog = []Model{ | |||||
| 245 | }, | 245 | }, | ||
| 246 | 246 | ||||
| 247 | // ---- Speculative: OpenAI ---- | 247 | // ---- Speculative: OpenAI ---- | ||
| 248 | - | // Context windows on the OpenAI gpt-5.x family differ by route: | 248 | + | // Public OpenAI API route. The ChatGPT/Codex subscription route is |
| 249 | - | // the direct API advertises 400k, the ChatGPT Codex OAuth backend | 249 | + | // represented separately below as provider "openai-codex". |
| 250 | - | // caps at 272k. zot serves both auth modes from one catalog row | |||
| 251 | - | // per id, so we pin to the smaller number to keep the context-usage | |||
| 252 | - | // meter honest under subscription auth. Users on the direct API | |||
| 253 | - | // simply see a conservative headroom estimate. | |||
| 254 | { | 250 | { | ||
| 255 | Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1", | 251 | Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1", | ||
| 256 | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | 252 | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | ||
| @@ -279,12 +275,7 @@ var Catalog = []Model{ | |||||
| 279 | }, | 275 | }, | ||
| 280 | { | 276 | { | ||
| 281 | Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini", | 277 | Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini", | ||
| 282 | - | // ContextWindow: 400k on the OpenAI direct API, 272k on the | 278 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, |
| 283 | - | // ChatGPT Codex OAuth backend. We pin to the smaller Codex | |||
| 284 | - | // cap so the context-usage meter is honest under subscription | |||
| 285 | - | // auth; direct-API users simply see a conservative headroom | |||
| 286 | - | // estimate rather than an inflated one. | |||
| 287 | - | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 288 | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | 279 | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | ||
| 289 | Speculative: true, | 280 | Speculative: true, | ||
| 290 | }, | 281 | }, | ||
| @@ -300,6 +291,52 @@ var Catalog = []Model{ | |||||
| 300 | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | 291 | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | ||
| 301 | Speculative: true, | 292 | Speculative: true, | ||
| 302 | }, | 293 | }, | ||
| 294 | + | ||||
| 295 | + | // ---- OpenAI Codex / ChatGPT subscription backend ---- | |||
| 296 | + | // Same model ids as the OpenAI family, but routed through the | |||
| 297 | + | // ChatGPT Codex OAuth backend rather than api.openai.com. | |||
| 298 | + | { | |||
| 299 | + | Provider: "openai-codex", ID: "gpt-5.2", DisplayName: "GPT-5.2 Codex", | |||
| 300 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 301 | + | PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, | |||
| 302 | + | Speculative: true, | |||
| 303 | + | }, | |||
| 304 | + | { | |||
| 305 | + | Provider: "openai-codex", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3 Codex", | |||
| 306 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 307 | + | PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, | |||
| 308 | + | Speculative: true, | |||
| 309 | + | }, | |||
| 310 | + | { | |||
| 311 | + | Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark", | |||
| 312 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 313 | + | PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, | |||
| 314 | + | Speculative: true, | |||
| 315 | + | }, | |||
| 316 | + | { | |||
| 317 | + | Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4 Codex", | |||
| 318 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 319 | + | PriceInput: 2.50, PriceOutput: 15.00, PriceCacheRead: 0.25, | |||
| 320 | + | Speculative: true, | |||
| 321 | + | }, | |||
| 322 | + | { | |||
| 323 | + | Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini Codex", | |||
| 324 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 325 | + | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | |||
| 326 | + | Speculative: true, | |||
| 327 | + | }, | |||
| 328 | + | { | |||
| 329 | + | Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5 Codex", | |||
| 330 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 331 | + | PriceInput: 2.50, PriceOutput: 15.00, PriceCacheRead: 0.25, | |||
| 332 | + | Speculative: true, | |||
| 333 | + | }, | |||
| 334 | + | { | |||
| 335 | + | Provider: "openai-codex", ID: "gpt-5.5-mini", DisplayName: "GPT-5.5 mini Codex", | |||
| 336 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 337 | + | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | |||
| 338 | + | Speculative: true, | |||
| 339 | + | }, | |||
| 303 | } | 340 | } | ||
| 304 | 341 | ||||
| 305 | // DefaultModel is used when the user does not specify one. | 342 | // DefaultModel is used when the user does not specify one. | ||
M internal/provider/openai_codex.go +10 -4
| @@ -57,7 +57,7 @@ func NewOpenAICodex(token, accountID, baseURL string) Client { | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | func (c *codexClient) Name() string { return "openai" } | |
| 60 | + | func (c *codexClient) Name() string { return "openai-codex" } | |
| 61 | 61 | ||
| 62 | 62 | // ---- Responses API wire types (subset needed for zot's surface) ---- | |
| 63 | 63 | ||
| @@ -146,7 +146,10 @@ type codexRequest struct { | |||
| 146 | 146 | // ---- Request building ---- | |
| 147 | 147 | ||
| 148 | 148 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |
| 149 | - | m, err := FindModel("openai", req.Model) | |
| 149 | + | m, err := FindModel("openai-codex", req.Model) | |
| 150 | + | if err != nil { | |
| 151 | + | m, err = FindModel("openai", req.Model) | |
| 152 | + | } | |
| 150 | 153 | if err != nil { | |
| 151 | 154 | return nil, err | |
| 152 | 155 | } | |
| @@ -324,8 +327,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 324 | 327 | defer close(out) | |
| 325 | 328 | defer resp.Body.Close() | |
| 326 | 329 | ||
| 327 | - | model, _ := FindModel("openai", req.Model) | |
| 328 | - | out <- EventStart{Model: req.Model, Provider: "openai"} | |
| 330 | + | model, _ := FindModel("openai-codex", req.Model) | |
| 331 | + | if model.ID == "" { | |
| 332 | + | model, _ = FindModel("openai", req.Model) | |
| 333 | + | } | |
| 334 | + | out <- EventStart{Model: req.Model, Provider: "openai-codex"} | |
| 329 | 335 | ||
| 330 | 336 | raw := make(chan sseEvent, 16) | |
| 331 | 337 | go readSSE(resp.Body, raw) | |
| @@ -57,7 +57,7 @@ func NewOpenAICodex(token, accountID, baseURL string) Client { | |||||
| 57 | } | 57 | } | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | - | func (c *codexClient) Name() string { return "openai" } | 60 | + | func (c *codexClient) Name() string { return "openai-codex" } |
| 61 | 61 | ||||
| 62 | // ---- Responses API wire types (subset needed for zot's surface) ---- | 62 | // ---- Responses API wire types (subset needed for zot's surface) ---- | ||
| 63 | 63 | ||||
| @@ -146,7 +146,10 @@ type codexRequest struct { | |||||
| 146 | // ---- Request building ---- | 146 | // ---- Request building ---- | ||
| 147 | 147 | ||||
| 148 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | 148 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | ||
| 149 | - | m, err := FindModel("openai", req.Model) | 149 | + | m, err := FindModel("openai-codex", req.Model) |
| 150 | + | if err != nil { | |||
| 151 | + | m, err = FindModel("openai", req.Model) | |||
| 152 | + | } | |||
| 150 | if err != nil { | 153 | if err != nil { | ||
| 151 | return nil, err | 154 | return nil, err | ||
| 152 | } | 155 | } | ||
| @@ -324,8 +327,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||||
| 324 | defer close(out) | 327 | defer close(out) | ||
| 325 | defer resp.Body.Close() | 328 | defer resp.Body.Close() | ||
| 326 | 329 | ||||
| 327 | - | model, _ := FindModel("openai", req.Model) | 330 | + | model, _ := FindModel("openai-codex", req.Model) |
| 328 | - | out <- EventStart{Model: req.Model, Provider: "openai"} | 331 | + | if model.ID == "" { |
| 332 | + | model, _ = FindModel("openai", req.Model) | |||
| 333 | + | } | |||
| 334 | + | out <- EventStart{Model: req.Model, Provider: "openai-codex"} | |||
| 329 | 335 | ||||
| 330 | raw := make(chan sseEvent, 16) | 336 | raw := make(chan sseEvent, 16) | ||
| 331 | go readSSE(resp.Body, raw) | 337 | go readSSE(resp.Body, raw) | ||
M internal/provider/usermodels.go +2 -3
| @@ -70,11 +70,10 @@ func LoadUserModels(path string) []Model { | |||
| 70 | 70 | ||
| 71 | 71 | var out []Model | |
| 72 | 72 | for providerName, prov := range file.Providers { | |
| 73 | - | // Normalize provider name: strip suffixes like "-codex" so | |
| 74 | - | // "openai-codex" maps to "openai". | |
| 73 | + | // Normalize legacy transport aliases to their provider names. | |
| 75 | 74 | normalized := providerName | |
| 76 | 75 | switch providerName { | |
| 77 | - | case "openai-codex", "openai-responses": | |
| 76 | + | case "openai-responses": | |
| 78 | 77 | normalized = "openai" | |
| 79 | 78 | case "anthropic-messages": | |
| 80 | 79 | normalized = "anthropic" | |
| @@ -70,11 +70,10 @@ func LoadUserModels(path string) []Model { | |||||
| 70 | 70 | ||||
| 71 | var out []Model | 71 | var out []Model | ||
| 72 | for providerName, prov := range file.Providers { | 72 | for providerName, prov := range file.Providers { | ||
| 73 | - | // Normalize provider name: strip suffixes like "-codex" so | 73 | + | // Normalize legacy transport aliases to their provider names. |
| 74 | - | // "openai-codex" maps to "openai". | |||
| 75 | normalized := providerName | 74 | normalized := providerName | ||
| 76 | switch providerName { | 75 | switch providerName { | ||
| 77 | - | case "openai-codex", "openai-responses": | 76 | + | case "openai-responses": |
| 78 | normalized = "openai" | 77 | normalized = "openai" | ||
| 79 | case "anthropic-messages": | 78 | case "anthropic-messages": | ||
| 80 | normalized = "anthropic" | 79 | normalized = "anthropic" | ||