detached 14ffdb6

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) {
357357 row{"zot tg ...", "short alias for telegram-bot"},
358358 )
359359 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)"},
361361 row{"--model ID", "model id (see --list-models)"},
362362 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
363363 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 {
132132 switch prov {
133133 case "openai":
134134 return "gpt-5"
135+ case "openai-codex":
136+ return "gpt-5.5"
135137 case "kimi":
136138 return "kimi-for-coding"
137139 case "deepseek":
@@ -156,13 +158,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
156158
157159 // User-requested provider (explicit > config > default).
158160 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" {
160162 // Unknown provider (maybe removed or renamed). Fall back to
161163 // the first provider that has credentials, or anthropic.
162164 provName = "anthropic"
163165 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
164166 provName = "openai"
165167 }
168+ if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {
169+ provName = "openai-codex"
170+ }
166171 if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
167172 provName = "kimi"
168173 }
@@ -200,7 +205,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
200205 // never shows a "not logged in" banner.
201206 userPickedProvider := args.Provider != ""
202207 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"} {
204209 if other == provName {
205210 continue
206211 }
@@ -217,6 +222,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
217222 switch provName {
218223 case "openai":
219224 model = "gpt-5"
225+ case "openai-codex":
226+ model = "gpt-5.5"
220227 case "kimi":
221228 model = "kimi-for-coding"
222229 case "deepseek":
@@ -236,6 +243,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
236243 switch provName {
237244 case "openai":
238245 model = "gpt-5"
246+ case "openai-codex":
247+ model = "gpt-5.5"
239248 case "kimi":
240249 model = "kimi-for-coding"
241250 case "deepseek":
@@ -521,11 +530,10 @@ func (r Resolved) NewClient() provider.Client {
521530 case "deepseek":
522531 return provider.NewDeepSeek(r.Credential, r.BaseURL)
523532 case "openai":
524- if r.AuthMethod == "oauth" {
525- inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
526- return r.wrapWithRefresh(inner)
527- }
528533 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)
529537 case "google":
530538 // API-key only path. Gemini Generative Language API.
531539 return provider.NewGemini(r.Credential, r.BaseURL)
@@ -543,11 +551,15 @@ func (r Resolved) NewClient() provider.Client {
543551 // sessions (hours) silently fail when the 1-hour token expires.
544552 func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
545553 provName := r.Provider
554+ tokenProvider := provName
555+ if provName == "openai-codex" {
556+ tokenProvider = "openai"
557+ }
546558 baseURL := r.BaseURL
547559 accountID := r.AccountID
548560
549561 refreshFn := func(ctx context.Context) (string, error) {
550- tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
562+ tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
551563 if err != nil {
552564 return "", err
553565 }
@@ -556,7 +568,7 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
556568
557569 factory := func(token string) provider.Client {
558570 switch provName {
559- case "openai":
571+ case "openai-codex":
560572 return provider.NewOpenAICodex(token, accountID, baseURL)
561573 case "kimi":
562574 return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
@@ -671,7 +683,7 @@ func kimiCodeHeaders() map[string]string {
671683
672684 func envVarName(provider string) string {
673685 switch provider {
674- case "openai":
686+ case "openai", "openai-codex":
675687 return "OPENAI"
676688 case "kimi":
677689 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 to162 // 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 continue210 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.Provider553 provName := r.Provider
554+ tokenProvider := provName
555+ if provName == "openai-codex" {
556+ tokenProvider = "openai"
557+ }
546 baseURL := r.BaseURL558 baseURL := r.BaseURL
547 accountID := r.AccountID559 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 "", err564 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 {
902902 BuildAgentForRescue: buildAgentForRescue,
903903 LoggedInProviders: func() []string {
904904 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"} {
906906 if _, _, err := ResolveCredential(p, ""); err == nil {
907907 out = append(out, p)
908908 }
@@ -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 []string904 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
141141 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
142142 return v, "apikey", "", nil
143143 }
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.
144148 case "kimi":
145149 if v := os.Getenv("KIMI_API_KEY"); v != "" {
146150 return v, "apikey", "", nil
@@ -180,6 +184,7 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
180184 if c.OpenAI.APIKey != "" {
181185 return c.OpenAI.APIKey, "apikey", "", nil
182186 }
187+ case "openai-codex":
183188 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
184189 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
185190 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", "", nil142 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", "", nil150 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", "", nil185 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, nil190 return tok.AccessToken, "oauth", tok.AccountID, nil
M internal/agent/modes/interactive.go +20 -5
@@ -2907,7 +2907,7 @@ func (i *Interactive) openLogoutDialog() {
29072907 }
29082908
29092909 var items []logoutItem
2910- for _, p := range []string{"anthropic", "openai", "kimi", "google"} {
2910+ for _, p := range []string{"anthropic", "kimi", "google"} {
29112911 if creds.Has(p) {
29122912 method := creds.Method(p)
29132913 if method == "oauth" {
@@ -2920,6 +2920,12 @@ func (i *Interactive) openLogoutDialog() {
29202920 })
29212921 }
29222922 }
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+ }
29232929 if len(items) == 0 {
29242930 i.mu.Lock()
29252931 i.statusOK = "no credentials stored; already logged out"
@@ -2960,12 +2966,12 @@ func (i *Interactive) doLogout(target string) {
29602966 var providers []string
29612967 switch target {
29622968 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":
29652971 providers = []string{target}
29662972 default:
29672973 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)"
29692975 i.mu.Unlock()
29702976 return
29712977 }
@@ -2973,7 +2979,16 @@ func (i *Interactive) doLogout(target string) {
29732979 var errs []string
29742980 clearedCurrent := false
29752981 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 {
29772992 errs = append(errs, p+": "+err.Error())
29782993 continue
29792994 }
@@ -2907,7 +2907,7 @@ func (i *Interactive) openLogoutDialog() {
2907 }2907 }
2908 2908
2909 var items []logoutItem2909 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 []string2966 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 return2976 return
2971 }2977 }
@@ -2973,7 +2979,16 @@ func (i *Interactive) doLogout(target string) {
2973 var errs []string2979 var errs []string
2974 clearedCurrent := false2980 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 continue2993 continue
2979 }2994 }
M internal/agent/modes/login_dialog.go +19 -8
@@ -28,7 +28,7 @@ const (
2828 type loginDialog struct {
2929 step loginStep
3030 method string // "apikey" | "oauth"
31- provider string // "anthropic" | "openai" | "kimi" | "google"
31+ provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google"
3232 message string
3333 success bool
3434 url string
@@ -39,8 +39,8 @@ type loginDialog struct {
3939 // provider, captured when Open() runs. Rendered above the
4040 // method picker so the user can see whether they're already
4141 // 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).
4444 status map[string]string
4545 }
4646
@@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) {
6565 d.success = false
6666 d.url = ""
6767 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": ""}
6969 // Best-effort: if the auth file can't be read, treat every
7070 // provider as not-logged-in. The status line just won't show
7171 // anything useful in that case, which is fine — the user
@@ -73,7 +73,14 @@ func (d *loginDialog) Open(zotHome string) {
7373 path := filepath.Join(zotHome, "auth.json")
7474 if creds, err := auth.NewStore(path).Load(); err == nil {
7575 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+ }
7784 d.status["kimi"] = creds.Method("kimi")
7885 d.status["deepseek"] = creds.Method("deepseek")
7986 d.status["google"] = creds.Method("google")
@@ -205,7 +212,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
205212 // subscription product at all).
206213 func providersForMethod(method string) []string {
207214 if method == "oauth" {
208- return []string{"anthropic", "openai", "kimi"}
215+ return []string{"anthropic", "openai-codex", "kimi"}
209216 }
210217 return []string{"anthropic", "openai", "kimi", "deepseek", "google"}
211218 }
@@ -216,7 +223,9 @@ func providerLabel(id string) string {
216223 case "anthropic":
217224 return "Anthropic (Claude Pro/Max)"
218225 case "openai":
219- return "OpenAI (ChatGPT Plus/Pro)"
226+ return "OpenAI"
227+ case "openai-codex":
228+ return "OpenAI Codex (ChatGPT Plus/Pro)"
220229 case "kimi":
221230 return "Kimi Code"
222231 case "deepseek":
@@ -240,10 +249,11 @@ func providerLabel(id string) string {
240249 func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
241250 anth := d.status["anthropic"]
242251 op := d.status["openai"]
252+ codex := d.status["openai-codex"]
243253 kimi := d.status["kimi"]
244254 ds := d.status["deepseek"]
245255 goog := d.status["google"]
246- if anth == "" && op == "" && kimi == "" && ds == "" && goog == "" {
256+ if anth == "" && op == "" && codex == "" && kimi == "" && ds == "" && goog == "" {
247257 return nil
248258 }
249259 row := func(id, method string) string {
@@ -265,6 +275,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
265275 return []string{
266276 row("anthropic", anth),
267277 row("openai", op),
278+ row("openai-codex", codex),
268279 row("kimi", kimi),
269280 row("deepseek", ds),
270281 row("google", goog),
@@ -28,7 +28,7 @@ const (
28 type loginDialog struct {28 type loginDialog struct {
29 step loginStep29 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 string32 message string
33 success bool33 success bool
34 url string34 url string
@@ -39,8 +39,8 @@ type loginDialog struct {
39 // provider, captured when Open() runs. Rendered above the39 // provider, captured when Open() runs. Rendered above the
40 // method picker so the user can see whether they're already40 // 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]string44 status map[string]string
45 }45 }
46 46
@@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) {
65 d.success = false65 d.success = false
66 d.url = ""66 d.url = ""
67 d.cursor = 067 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 every69 // Best-effort: if the auth file can't be read, treat every
70 // provider as not-logged-in. The status line just won't show70 // provider as not-logged-in. The status line just won't show
71 // anything useful in that case, which is fine — the user71 // 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 nil257 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 {
3434 oauthCtx context.Context
3535 oauthCancel context.CancelFunc
3636
37- manualOp *OAuthProvider
38- manualPKCE PKCE
39- manualState string
37+ manualOp *OAuthProvider
38+ manualStoreProvider string
39+ manualEventProvider string
40+ manualPKCE PKCE
41+ manualState string
4042 }
4143
4244 // NewManager returns a Manager bound to store.
@@ -133,18 +135,20 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
133135 if provider == "kimi" {
134136 return m.StartKimiDeviceOAuth()
135137 }
138+ storeProvider := provider
136139 var op OAuthProvider
137140 switch provider {
138141 case "anthropic":
139142 op = AnthropicOAuth
140- case "openai":
143+ case "openai", "openai-codex":
141144 op = OpenAIOAuth
145+ storeProvider = "openai"
142146 case "google":
143147 return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
144148 case "deepseek":
145149 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
146150 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")
148152 }
149153
150154 m.mu.Lock()
@@ -178,13 +182,13 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
178182 m.oauthCancel = cancel
179183 m.mu.Unlock()
180184
181- go m.awaitOAuth(ctx, op, cs, pkce, state)
185+ go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state)
182186 go m.maybeOpen(authURL)
183187 m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL})
184188 return authURL, nil
185189 }
186190
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) {
188192 defer cs.Shutdown()
189193
190194 waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Minute)
@@ -192,12 +196,12 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback
192196 res, err := cs.Result(waitCtx)
193197 if err != nil {
194198 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"})
196200 }
197201 return
198202 }
199203 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()})
201205 return
202206 }
203207
@@ -205,14 +209,14 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback
205209 defer exCancel()
206210 tok, err := op.Exchange(exCtx, res.Code, res.State, pkce)
207211 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()})
209213 return
210214 }
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()})
213217 return
214218 }
215- m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})
219+ m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})
216220 }
217221
218222 // StartKimiDeviceOAuth starts Kimi Code's device-code subscription login.
@@ -258,18 +262,20 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
258262 if provider == "kimi" {
259263 return m.StartKimiDeviceOAuth()
260264 }
265+ storeProvider := provider
261266 var op OAuthProvider
262267 switch provider {
263268 case "anthropic":
264269 op = AnthropicManualOAuth
265- case "openai":
270+ case "openai", "openai-codex":
266271 op = OpenAIOAuth
272+ storeProvider = "openai"
267273 case "google":
268274 return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
269275 case "deepseek":
270276 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
271277 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")
273279 }
274280
275281 pkce, err := NewPKCE()
@@ -283,6 +289,8 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
283289
284290 m.mu.Lock()
285291 m.manualOp = &op
292+ m.manualStoreProvider = storeProvider
293+ m.manualEventProvider = provider
286294 m.manualPKCE = pkce
287295 m.manualState = state
288296 m.mu.Unlock()
@@ -297,6 +305,8 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
297305 func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
298306 m.mu.Lock()
299307 op := m.manualOp
308+ storeProvider := m.manualStoreProvider
309+ eventProvider := m.manualEventProvider
300310 pkce := m.manualPKCE
301311 state := m.manualState
302312 m.mu.Unlock()
@@ -314,19 +324,21 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
314324 defer cancel()
315325 tok, err := op.Exchange(exCtx, code, state, pkce)
316326 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()})
318328 return err
319329 }
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()})
322332 return err
323333 }
324334 m.mu.Lock()
325335 m.manualOp = nil
336+ m.manualStoreProvider = ""
337+ m.manualEventProvider = ""
326338 m.manualPKCE = PKCE{}
327339 m.manualState = ""
328340 m.mu.Unlock()
329- m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})
341+ m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})
330342 return nil
331343 }
332344
@@ -34,9 +34,11 @@ type Manager struct {
34 oauthCtx context.Context34 oauthCtx context.Context
35 oauthCancel context.CancelFunc35 oauthCancel context.CancelFunc
36 36
37- manualOp *OAuthProvider37+ manualOp *OAuthProvider
38- manualPKCE PKCE38+ manualStoreProvider string
39- manualState string39+ 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 OAuthProvider139 var op OAuthProvider
137 switch provider {140 switch provider {
138 case "anthropic":141 case "anthropic":
139 op = AnthropicOAuth142 op = AnthropicOAuth
140- case "openai":143+ case "openai", "openai-codex":
141 op = OpenAIOAuth144 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 = cancel182 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, nil188 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 return201 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 return205 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 return213 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 return217 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 OAuthProvider266 var op OAuthProvider
262 switch provider {267 switch provider {
263 case "anthropic":268 case "anthropic":
264 op = AnthropicManualOAuth269 op = AnthropicManualOAuth
265- case "openai":270+ case "openai", "openai-codex":
266 op = OpenAIOAuth271 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 = &op291 m.manualOp = &op
292+ m.manualStoreProvider = storeProvider
293+ m.manualEventProvider = provider
286 m.manualPKCE = pkce294 m.manualPKCE = pkce
287 m.manualState = state295 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.manualOp307 op := m.manualOp
308+ storeProvider := m.manualStoreProvider
309+ eventProvider := m.manualEventProvider
300 pkce := m.manualPKCE310 pkce := m.manualPKCE
301 state := m.manualState311 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 err328 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 err332 return err
323 }333 }
324 m.mu.Lock()334 m.mu.Lock()
325 m.manualOp = nil335 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 nil342 return nil
331 }343 }
332 344
M internal/auth/store.go +41 -4
@@ -23,8 +23,9 @@ type Credentials struct {
2323 DeepSeek ProviderCreds `json:"deepseek,omitempty"`
2424 }
2525
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.
2829 type ProviderCreds struct {
2930 APIKey string `json:"api_key,omitempty"`
3031 OAuth *OAuthToken `json:"oauth,omitempty"`
@@ -138,7 +139,9 @@ func (s *Store) SetAPIKey(provider, key string) error {
138139 return fmt.Errorf("unknown provider %q", provider)
139140 }
140141 p.APIKey = key
141- p.OAuth = nil
142+ if provider != "openai" {
143+ p.OAuth = nil
144+ }
142145 return s.saveLocked(c)
143146 }
144147
@@ -154,7 +157,9 @@ func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
154157 if p == nil {
155158 return fmt.Errorf("unknown provider %q", provider)
156159 }
157- p.APIKey = ""
160+ if provider != "openai" {
161+ p.APIKey = ""
162+ }
158163 p.OAuth = &tok
159164 return s.saveLocked(c)
160165 }
@@ -175,6 +180,38 @@ func (s *Store) Clear(provider string) error {
175180 return s.saveLocked(c)
176181 }
177182
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+
178215 func (s *Store) saveLocked(c Credentials) error {
179216 if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
180217 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 of26+// 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 = key141 p.APIKey = key
141- p.OAuth = nil142+ 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 = &tok163 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 err217 return err
M internal/provider/models.go +49 -12
@@ -245,12 +245,8 @@ var Catalog = []Model{
245245 },
246246
247247 // ---- 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".
254250 {
255251 Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1",
256252 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
@@ -279,12 +275,7 @@ var Catalog = []Model{
279275 },
280276 {
281277 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,
288279 PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075,
289280 Speculative: true,
290281 },
@@ -300,6 +291,52 @@ var Catalog = []Model{
300291 PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075,
301292 Speculative: true,
302293 },
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+ },
303340 }
304341
305342 // 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 backend249+ // 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 the278+ 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 {
5757 }
5858 }
5959
60-func (c *codexClient) Name() string { return "openai" }
60+func (c *codexClient) Name() string { return "openai-codex" }
6161
6262 // ---- Responses API wire types (subset needed for zot's surface) ----
6363
@@ -146,7 +146,10 @@ type codexRequest struct {
146146 // ---- Request building ----
147147
148148 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+ }
150153 if err != nil {
151154 return nil, err
152155 }
@@ -324,8 +327,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
324327 defer close(out)
325328 defer resp.Body.Close()
326329
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"}
329335
330336 raw := make(chan sseEvent, 16)
331337 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, err154 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 {
7070
7171 var out []Model
7272 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.
7574 normalized := providerName
7675 switch providerName {
77- case "openai-codex", "openai-responses":
76+ case "openai-responses":
7877 normalized = "openai"
7978 case "anthropic-messages":
8079 normalized = "anthropic"
@@ -70,11 +70,10 @@ func LoadUserModels(path string) []Model {
70 70
71 var out []Model71 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" so73+ // Normalize legacy transport aliases to their provider names.
74- // "openai-codex" maps to "openai".
75 normalized := providerName74 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"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close