detached b6d6f7a

fix(models): discover available Codex models (#107)

* fix(models): discover available Codex models

* fix(models): scope discovered catalogs to credentials

* fix(models): harden Codex catalog refresh

* test(provider): skip POSIX mode check on Windows
commit
b6d6f7a7712987004eea3239b405f53b5513e4bc
author
bnema · · 2026-08-11 03:48 UTC
committer
GitHub · · 2026-08-11 03:48 UTC
parent
e1478e1
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +464 -31
M packages/agent/config.go +13 -7
@@ -601,31 +601,31 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma
601601 return key, "apikey", "", nil
602602 }
603603 if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {
604- tok, _ := refreshIfExpired(provider, pc.OAuth)
604+ tok, _ := refreshIfExpiredContext(ctx, provider, pc.OAuth)
605605 return tok.AccessToken, "oauth", "", nil
606606 }
607607 }
608608 switch provider {
609609 case "anthropic":
610610 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
611- tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
611+ tok, _ := refreshIfExpiredContext(ctx, "anthropic", c.Anthropic.OAuth)
612612 return tok.AccessToken, "oauth", "", nil
613613 }
614614 case "openai-codex":
615615 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
616- tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
616+ tok, _ := refreshIfExpiredContext(ctx, "openai", c.OpenAI.OAuth)
617617 return tok.AccessToken, "oauth", tok.AccountID, nil
618618 }
619619 case "kimi":
620620 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
621- tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
621+ tok, _ := refreshIfExpiredContext(ctx, "kimi", c.Kimi.OAuth)
622622 return tok.AccessToken, "oauth", "", nil
623623 }
624624 if kimiCLIFallbackDisabled() {
625625 break
626626 }
627627 if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
628- tok, _ = refreshIfExpired("kimi", tok)
628+ tok, _ = refreshIfExpiredContext(ctx, "kimi", tok)
629629 return tok.AccessToken, "oauth", "", nil
630630 }
631631 case "github-copilot":
@@ -794,6 +794,12 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
794794 // a request with the stale access_token, which will 401. That's still
795795 // better than crashing at credential-resolution time.
796796 func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
797+ return refreshIfExpiredContext(context.Background(), providerName, tok)
798+}
799+
800+// refreshIfExpiredContext uses the caller's cancellation deadline while
801+// refreshing an expired OAuth token.
802+func refreshIfExpiredContext(parent context.Context, providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
797803 if tok == nil {
798804 return &auth.OAuthToken{}, fmt.Errorf("nil token")
799805 }
@@ -805,7 +811,7 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok
805811 }
806812
807813 if providerName == "xai" {
808- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
814+ ctx, cancel := context.WithTimeout(parent, 30*time.Second)
809815 defer cancel()
810816 next, err := auth.RefreshXAIToken(ctx, tok.RefreshToken)
811817 if err != nil {
@@ -832,7 +838,7 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok
832838 return tok, fmt.Errorf("unknown provider %q", providerName)
833839 }
834840
835- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
841+ ctx, cancel := context.WithTimeout(parent, 30*time.Second)
836842 defer cancel()
837843 next, err := op.Refresh(ctx, tok.RefreshToken)
838844 if err != nil {
@@ -601,31 +601,31 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma
601 return key, "apikey", "", nil601 return key, "apikey", "", nil
602 }602 }
603 if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {603 if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {
604- tok, _ := refreshIfExpired(provider, pc.OAuth)604+ tok, _ := refreshIfExpiredContext(ctx, provider, pc.OAuth)
605 return tok.AccessToken, "oauth", "", nil605 return tok.AccessToken, "oauth", "", nil
606 }606 }
607 }607 }
608 switch provider {608 switch provider {
609 case "anthropic":609 case "anthropic":
610 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {610 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
611- tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)611+ tok, _ := refreshIfExpiredContext(ctx, "anthropic", c.Anthropic.OAuth)
612 return tok.AccessToken, "oauth", "", nil612 return tok.AccessToken, "oauth", "", nil
613 }613 }
614 case "openai-codex":614 case "openai-codex":
615 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {615 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
616- tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)616+ tok, _ := refreshIfExpiredContext(ctx, "openai", c.OpenAI.OAuth)
617 return tok.AccessToken, "oauth", tok.AccountID, nil617 return tok.AccessToken, "oauth", tok.AccountID, nil
618 }618 }
619 case "kimi":619 case "kimi":
620 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {620 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
621- tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)621+ tok, _ := refreshIfExpiredContext(ctx, "kimi", c.Kimi.OAuth)
622 return tok.AccessToken, "oauth", "", nil622 return tok.AccessToken, "oauth", "", nil
623 }623 }
624 if kimiCLIFallbackDisabled() {624 if kimiCLIFallbackDisabled() {
625 break625 break
626 }626 }
627 if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {627 if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
628- tok, _ = refreshIfExpired("kimi", tok)628+ tok, _ = refreshIfExpiredContext(ctx, "kimi", tok)
629 return tok.AccessToken, "oauth", "", nil629 return tok.AccessToken, "oauth", "", nil
630 }630 }
631 case "github-copilot":631 case "github-copilot":
@@ -794,6 +794,12 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
794 // a request with the stale access_token, which will 401. That's still794 // a request with the stale access_token, which will 401. That's still
795 // better than crashing at credential-resolution time.795 // better than crashing at credential-resolution time.
796 func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {796 func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
797+ return refreshIfExpiredContext(context.Background(), providerName, tok)
798+}
799+
800+// refreshIfExpiredContext uses the caller's cancellation deadline while
801+// refreshing an expired OAuth token.
802+func refreshIfExpiredContext(parent context.Context, providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
797 if tok == nil {803 if tok == nil {
798 return &auth.OAuthToken{}, fmt.Errorf("nil token")804 return &auth.OAuthToken{}, fmt.Errorf("nil token")
799 }805 }
@@ -805,7 +811,7 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok
805 }811 }
806 812
807 if providerName == "xai" {813 if providerName == "xai" {
808- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)814+ ctx, cancel := context.WithTimeout(parent, 30*time.Second)
809 defer cancel()815 defer cancel()
810 next, err := auth.RefreshXAIToken(ctx, tok.RefreshToken)816 next, err := auth.RefreshXAIToken(ctx, tok.RefreshToken)
811 if err != nil {817 if err != nil {
@@ -832,7 +838,7 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok
832 return tok, fmt.Errorf("unknown provider %q", providerName)838 return tok, fmt.Errorf("unknown provider %q", providerName)
833 }839 }
834 840
835- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)841+ ctx, cancel := context.WithTimeout(parent, 30*time.Second)
836 defer cancel()842 defer cancel()
837 next, err := op.Refresh(ctx, tok.RefreshToken)843 next, err := op.Refresh(ctx, tok.RefreshToken)
838 if err != nil {844 if err != nil {
M packages/agent/modelsync.go +109 -7
@@ -29,11 +29,88 @@ func LoadCachedModels() {
2929 if err != nil {
3030 return
3131 }
32- if len(c.Models) > 0 {
33- provider.SetLiveModels(c.Models)
32+ c = filterCacheByProviderScopes(c, currentModelProviderScopes())
33+ if len(c.Models) > 0 || len(c.AuthoritativeProviders) > 0 {
34+ provider.SetLiveModelsForProviders(c.Models, c.AuthoritativeProviders)
3435 }
3536 }
3637
38+// currentModelProviderScopes returns identifiers that make a provider's
39+// discovered catalog account-specific. The cache remains provider-neutral:
40+// each provider supplies an opaque scope value when it needs one.
41+func currentModelProviderScopes() map[string]string {
42+ scopes := make(map[string]string)
43+ if token := loadOAuthToken("openai"); token != nil && token.AccountID != "" {
44+ scopes["openai-codex"] = token.AccountID
45+ }
46+ return scopes
47+}
48+
49+// filterCacheByProviderScopes removes cached entries whose provider scope no
50+// longer matches the active credential. It also removes the authoritative
51+// marker so that a failed refresh falls back to the baked-in catalog instead
52+// of treating another account's empty catalog as authoritative.
53+func filterCacheByProviderScopes(c provider.ModelCache, scopes map[string]string) provider.ModelCache {
54+ out := provider.ModelCache{
55+ FetchedAt: c.FetchedAt,
56+ Models: append([]provider.Model(nil), c.Models...),
57+ AuthoritativeProviders: append([]string(nil), c.AuthoritativeProviders...),
58+ ProviderScopes: make(map[string]string, len(c.ProviderScopes)),
59+ }
60+ for name, scope := range c.ProviderScopes {
61+ out.ProviderScopes[name] = scope
62+ }
63+
64+ scopedProviders := map[string]struct{}{"openai-codex": {}}
65+ for name := range c.ProviderScopes {
66+ scopedProviders[name] = struct{}{}
67+ }
68+ for name := range scopes {
69+ scopedProviders[name] = struct{}{}
70+ }
71+ for name := range scopedProviders {
72+ if cachedScope := c.ProviderScopes[name]; cachedScope != "" && cachedScope == scopes[name] {
73+ continue
74+ }
75+ out.Models = filterModelsByProvider(out.Models, name)
76+ out.AuthoritativeProviders = filterProviderNames(out.AuthoritativeProviders, name)
77+ delete(out.ProviderScopes, name)
78+ }
79+ return out
80+}
81+
82+func filterModelsByProvider(models []provider.Model, name string) []provider.Model {
83+ out := make([]provider.Model, 0, len(models))
84+ for _, model := range models {
85+ if model.Provider != name {
86+ out = append(out, model)
87+ }
88+ }
89+ return out
90+}
91+
92+func filterProviderNames(names []string, target string) []string {
93+ out := make([]string, 0, len(names))
94+ for _, name := range names {
95+ if name != target {
96+ out = append(out, name)
97+ }
98+ }
99+ return out
100+}
101+
102+func providerScopesEqual(a, b map[string]string) bool {
103+ if len(a) != len(b) {
104+ return false
105+ }
106+ for name, scope := range a {
107+ if b[name] != scope {
108+ return false
109+ }
110+ }
111+ return true
112+}
113+
37114 // LoadUserModels reads $ZUT_HOME/models.json and merges any user-defined
38115 // models into the active catalog. User models take highest precedence.
39116 // Any validation issues (bad provider id, empty model id, malformed
@@ -174,7 +251,8 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e
174251
175252 func refreshModels() {
176253 cached, _ := provider.LoadCache(ModelCachePath())
177- if cached.IsFresh() {
254+ currentScopes := currentModelProviderScopes()
255+ if cached.IsFresh() && providerScopesEqual(cached.ProviderScopes, currentScopes) {
178256 return
179257 }
180258
@@ -182,6 +260,16 @@ func refreshModels() {
182260 defer cancel()
183261
184262 var all []provider.Model
263+ var authoritativeProviders []string
264+ providerScopes := make(map[string]string)
265+ if cached.IsFresh() {
266+ cached = filterCacheByProviderScopes(cached, currentScopes)
267+ all = append(all, cached.Models...)
268+ authoritativeProviders = append(authoritativeProviders, cached.AuthoritativeProviders...)
269+ for name, scope := range cached.ProviderScopes {
270+ providerScopes[name] = scope
271+ }
272+ }
185273
186274 if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {
187275 // /v1/models on Anthropic is API-key only; OAuth tokens can
@@ -196,6 +284,18 @@ func refreshModels() {
196284 all = append(all, live...)
197285 }
198286 }
287+ if cred, method, accountID, err := resolveCredentialFull(ctx, "openai-codex", "", apiKeyCommandSkip); err == nil && method == "oauth" {
288+ if live, err := provider.DiscoverOpenAICodex(ctx, cred, accountID, ""); err == nil {
289+ all = filterModelsByProvider(all, "openai-codex")
290+ authoritativeProviders = filterProviderNames(authoritativeProviders, "openai-codex")
291+ delete(providerScopes, "openai-codex")
292+ all = append(all, live...)
293+ if accountID != "" {
294+ authoritativeProviders = append(authoritativeProviders, "openai-codex")
295+ providerScopes["openai-codex"] = accountID
296+ }
297+ }
298+ }
199299 if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {
200300 if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
201301 for i := range live {
@@ -218,12 +318,14 @@ func refreshModels() {
218318 }
219319 }
220320
221- if len(all) == 0 {
321+ if len(all) == 0 && len(authoritativeProviders) == 0 {
222322 return
223323 }
224- provider.SetLiveModels(all)
324+ provider.SetLiveModelsForProviders(all, authoritativeProviders)
225325 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
226- FetchedAt: time.Now().UTC(),
227- Models: all,
326+ FetchedAt: time.Now().UTC(),
327+ Models: all,
328+ AuthoritativeProviders: authoritativeProviders,
329+ ProviderScopes: providerScopes,
228330 })
229331 }
@@ -29,11 +29,88 @@ func LoadCachedModels() {
29 if err != nil {29 if err != nil {
30 return30 return
31 }31 }
32- if len(c.Models) > 0 {32+ c = filterCacheByProviderScopes(c, currentModelProviderScopes())
33- provider.SetLiveModels(c.Models)33+ if len(c.Models) > 0 || len(c.AuthoritativeProviders) > 0 {
34+ provider.SetLiveModelsForProviders(c.Models, c.AuthoritativeProviders)
34 }35 }
35 }36 }
36 37
38+// currentModelProviderScopes returns identifiers that make a provider's
39+// discovered catalog account-specific. The cache remains provider-neutral:
40+// each provider supplies an opaque scope value when it needs one.
41+func currentModelProviderScopes() map[string]string {
42+ scopes := make(map[string]string)
43+ if token := loadOAuthToken("openai"); token != nil && token.AccountID != "" {
44+ scopes["openai-codex"] = token.AccountID
45+ }
46+ return scopes
47+}
48+
49+// filterCacheByProviderScopes removes cached entries whose provider scope no
50+// longer matches the active credential. It also removes the authoritative
51+// marker so that a failed refresh falls back to the baked-in catalog instead
52+// of treating another account's empty catalog as authoritative.
53+func filterCacheByProviderScopes(c provider.ModelCache, scopes map[string]string) provider.ModelCache {
54+ out := provider.ModelCache{
55+ FetchedAt: c.FetchedAt,
56+ Models: append([]provider.Model(nil), c.Models...),
57+ AuthoritativeProviders: append([]string(nil), c.AuthoritativeProviders...),
58+ ProviderScopes: make(map[string]string, len(c.ProviderScopes)),
59+ }
60+ for name, scope := range c.ProviderScopes {
61+ out.ProviderScopes[name] = scope
62+ }
63+
64+ scopedProviders := map[string]struct{}{"openai-codex": {}}
65+ for name := range c.ProviderScopes {
66+ scopedProviders[name] = struct{}{}
67+ }
68+ for name := range scopes {
69+ scopedProviders[name] = struct{}{}
70+ }
71+ for name := range scopedProviders {
72+ if cachedScope := c.ProviderScopes[name]; cachedScope != "" && cachedScope == scopes[name] {
73+ continue
74+ }
75+ out.Models = filterModelsByProvider(out.Models, name)
76+ out.AuthoritativeProviders = filterProviderNames(out.AuthoritativeProviders, name)
77+ delete(out.ProviderScopes, name)
78+ }
79+ return out
80+}
81+
82+func filterModelsByProvider(models []provider.Model, name string) []provider.Model {
83+ out := make([]provider.Model, 0, len(models))
84+ for _, model := range models {
85+ if model.Provider != name {
86+ out = append(out, model)
87+ }
88+ }
89+ return out
90+}
91+
92+func filterProviderNames(names []string, target string) []string {
93+ out := make([]string, 0, len(names))
94+ for _, name := range names {
95+ if name != target {
96+ out = append(out, name)
97+ }
98+ }
99+ return out
100+}
101+
102+func providerScopesEqual(a, b map[string]string) bool {
103+ if len(a) != len(b) {
104+ return false
105+ }
106+ for name, scope := range a {
107+ if b[name] != scope {
108+ return false
109+ }
110+ }
111+ return true
112+}
113+
37 // LoadUserModels reads $ZUT_HOME/models.json and merges any user-defined114 // LoadUserModels reads $ZUT_HOME/models.json and merges any user-defined
38 // models into the active catalog. User models take highest precedence.115 // models into the active catalog. User models take highest precedence.
39 // Any validation issues (bad provider id, empty model id, malformed116 // Any validation issues (bad provider id, empty model id, malformed
@@ -174,7 +251,8 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e
174 251
175 func refreshModels() {252 func refreshModels() {
176 cached, _ := provider.LoadCache(ModelCachePath())253 cached, _ := provider.LoadCache(ModelCachePath())
177- if cached.IsFresh() {254+ currentScopes := currentModelProviderScopes()
255+ if cached.IsFresh() && providerScopesEqual(cached.ProviderScopes, currentScopes) {
178 return256 return
179 }257 }
180 258
@@ -182,6 +260,16 @@ func refreshModels() {
182 defer cancel()260 defer cancel()
183 261
184 var all []provider.Model262 var all []provider.Model
263+ var authoritativeProviders []string
264+ providerScopes := make(map[string]string)
265+ if cached.IsFresh() {
266+ cached = filterCacheByProviderScopes(cached, currentScopes)
267+ all = append(all, cached.Models...)
268+ authoritativeProviders = append(authoritativeProviders, cached.AuthoritativeProviders...)
269+ for name, scope := range cached.ProviderScopes {
270+ providerScopes[name] = scope
271+ }
272+ }
185 273
186 if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {274 if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {
187 // /v1/models on Anthropic is API-key only; OAuth tokens can275 // /v1/models on Anthropic is API-key only; OAuth tokens can
@@ -196,6 +284,18 @@ func refreshModels() {
196 all = append(all, live...)284 all = append(all, live...)
197 }285 }
198 }286 }
287+ if cred, method, accountID, err := resolveCredentialFull(ctx, "openai-codex", "", apiKeyCommandSkip); err == nil && method == "oauth" {
288+ if live, err := provider.DiscoverOpenAICodex(ctx, cred, accountID, ""); err == nil {
289+ all = filterModelsByProvider(all, "openai-codex")
290+ authoritativeProviders = filterProviderNames(authoritativeProviders, "openai-codex")
291+ delete(providerScopes, "openai-codex")
292+ all = append(all, live...)
293+ if accountID != "" {
294+ authoritativeProviders = append(authoritativeProviders, "openai-codex")
295+ providerScopes["openai-codex"] = accountID
296+ }
297+ }
298+ }
199 if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {299 if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {
200 if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {300 if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
201 for i := range live {301 for i := range live {
@@ -218,12 +318,14 @@ func refreshModels() {
218 }318 }
219 }319 }
220 320
221- if len(all) == 0 {321+ if len(all) == 0 && len(authoritativeProviders) == 0 {
222 return322 return
223 }323 }
224- provider.SetLiveModels(all)324+ provider.SetLiveModelsForProviders(all, authoritativeProviders)
225 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{325 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
226- FetchedAt: time.Now().UTC(),326+ FetchedAt: time.Now().UTC(),
227- Models: all,327+ Models: all,
328+ AuthoritativeProviders: authoritativeProviders,
329+ ProviderScopes: providerScopes,
228 })330 })
229 }331 }
M packages/agent/modelsync_test.go +47 -0
@@ -13,6 +13,53 @@ import (
1313 "github.com/bnema/zut/packages/provider"
1414 )
1515
16+func TestFilterCacheByProviderScopesRemovesMismatchedProvider(t *testing.T) {
17+ cache := provider.ModelCache{
18+ Models: []provider.Model{
19+ {Provider: "openai-codex", ID: "account-a-only"},
20+ {Provider: "openai", ID: "shared"},
21+ },
22+ AuthoritativeProviders: []string{"openai-codex"},
23+ ProviderScopes: map[string]string{"openai-codex": "account-a"},
24+ }
25+
26+ filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-b"})
27+ if len(filtered.Models) != 1 || filtered.Models[0].Provider != "openai" {
28+ t.Fatalf("models = %+v, want only non-scoped model", filtered.Models)
29+ }
30+ if len(filtered.AuthoritativeProviders) != 0 {
31+ t.Fatalf("authoritative providers = %v, want none", filtered.AuthoritativeProviders)
32+ }
33+ if len(filtered.ProviderScopes) != 0 {
34+ t.Fatalf("provider scopes = %v, want none", filtered.ProviderScopes)
35+ }
36+}
37+
38+func TestFilterCacheByProviderScopesRetainsMatchingScopedProvider(t *testing.T) {
39+ cache := provider.ModelCache{
40+ Models: []provider.Model{{Provider: "openai-codex", ID: "account-a-only"}},
41+ AuthoritativeProviders: []string{"openai-codex"},
42+ ProviderScopes: map[string]string{"openai-codex": "account-a"},
43+ }
44+
45+ filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
46+ if len(filtered.Models) != 1 || len(filtered.AuthoritativeProviders) != 1 || filtered.ProviderScopes["openai-codex"] != "account-a" {
47+ t.Fatalf("matching scoped cache was not preserved: %+v", filtered)
48+ }
49+}
50+
51+func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T) {
52+ cache := provider.ModelCache{
53+ Models: []provider.Model{{Provider: "openai-codex", ID: "legacy"}},
54+ AuthoritativeProviders: []string{"openai-codex"},
55+ }
56+
57+ filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
58+ if len(filtered.Models) != 0 || len(filtered.AuthoritativeProviders) != 0 {
59+ t.Fatalf("legacy Codex cache was retained: %+v", filtered)
60+ }
61+}
62+
1663 func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
1764 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1865 if r.URL.Path != "/models" {
@@ -13,6 +13,53 @@ import (
13 "github.com/bnema/zut/packages/provider"13 "github.com/bnema/zut/packages/provider"
14 )14 )
15 15
16+func TestFilterCacheByProviderScopesRemovesMismatchedProvider(t *testing.T) {
17+ cache := provider.ModelCache{
18+ Models: []provider.Model{
19+ {Provider: "openai-codex", ID: "account-a-only"},
20+ {Provider: "openai", ID: "shared"},
21+ },
22+ AuthoritativeProviders: []string{"openai-codex"},
23+ ProviderScopes: map[string]string{"openai-codex": "account-a"},
24+ }
25+
26+ filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-b"})
27+ if len(filtered.Models) != 1 || filtered.Models[0].Provider != "openai" {
28+ t.Fatalf("models = %+v, want only non-scoped model", filtered.Models)
29+ }
30+ if len(filtered.AuthoritativeProviders) != 0 {
31+ t.Fatalf("authoritative providers = %v, want none", filtered.AuthoritativeProviders)
32+ }
33+ if len(filtered.ProviderScopes) != 0 {
34+ t.Fatalf("provider scopes = %v, want none", filtered.ProviderScopes)
35+ }
36+}
37+
38+func TestFilterCacheByProviderScopesRetainsMatchingScopedProvider(t *testing.T) {
39+ cache := provider.ModelCache{
40+ Models: []provider.Model{{Provider: "openai-codex", ID: "account-a-only"}},
41+ AuthoritativeProviders: []string{"openai-codex"},
42+ ProviderScopes: map[string]string{"openai-codex": "account-a"},
43+ }
44+
45+ filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
46+ if len(filtered.Models) != 1 || len(filtered.AuthoritativeProviders) != 1 || filtered.ProviderScopes["openai-codex"] != "account-a" {
47+ t.Fatalf("matching scoped cache was not preserved: %+v", filtered)
48+ }
49+}
50+
51+func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T) {
52+ cache := provider.ModelCache{
53+ Models: []provider.Model{{Provider: "openai-codex", ID: "legacy"}},
54+ AuthoritativeProviders: []string{"openai-codex"},
55+ }
56+
57+ filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
58+ if len(filtered.Models) != 0 || len(filtered.AuthoritativeProviders) != 0 {
59+ t.Fatalf("legacy Codex cache was retained: %+v", filtered)
60+ }
61+}
62+
16 func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {63 func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
17 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18 if r.URL.Path != "/models" {65 if r.URL.Path != "/models" {
M packages/provider/cache.go +5 -3
@@ -10,8 +10,10 @@ import (
1010
1111 // ModelCache is the on-disk shape for discovered models.
1212 type ModelCache struct {
13- FetchedAt time.Time `json:"fetched_at"`
14- Models []Model `json:"models"`
13+ FetchedAt time.Time `json:"fetched_at"`
14+ Models []Model `json:"models"`
15+ AuthoritativeProviders []string `json:"authoritative_providers,omitempty"`
16+ ProviderScopes map[string]string `json:"provider_scopes,omitempty"`
1517 }
1618
1719 // CacheTTL is how long a discovered list is considered fresh.
@@ -49,7 +51,7 @@ func SaveCache(path string, c ModelCache) error {
4951 return err
5052 }
5153 tmp := path + ".tmp"
52- if err := os.WriteFile(tmp, b, 0o644); err != nil {
54+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
5355 return err
5456 }
5557 return os.Rename(tmp, path)
@@ -10,8 +10,10 @@ import (
10 10
11 // ModelCache is the on-disk shape for discovered models.11 // ModelCache is the on-disk shape for discovered models.
12 type ModelCache struct {12 type ModelCache struct {
13- FetchedAt time.Time `json:"fetched_at"`13+ FetchedAt time.Time `json:"fetched_at"`
14- Models []Model `json:"models"`14+ Models []Model `json:"models"`
15+ AuthoritativeProviders []string `json:"authoritative_providers,omitempty"`
16+ ProviderScopes map[string]string `json:"provider_scopes,omitempty"`
15 }17 }
16 18
17 // CacheTTL is how long a discovered list is considered fresh.19 // CacheTTL is how long a discovered list is considered fresh.
@@ -49,7 +51,7 @@ func SaveCache(path string, c ModelCache) error {
49 return err51 return err
50 }52 }
51 tmp := path + ".tmp"53 tmp := path + ".tmp"
52- if err := os.WriteFile(tmp, b, 0o644); err != nil {54+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
53 return err55 return err
54 }56 }
55 return os.Rename(tmp, path)57 return os.Rename(tmp, path)
A packages/provider/cache_test.go +25 -0
@@ -0,0 +1,25 @@
1+package provider
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "runtime"
7+ "testing"
8+)
9+
10+func TestSaveCacheUsesRestrictivePermissions(t *testing.T) {
11+ if runtime.GOOS == "windows" {
12+ t.Skip("Windows does not enforce POSIX file permissions")
13+ }
14+ path := filepath.Join(t.TempDir(), "models.json")
15+ if err := SaveCache(path, ModelCache{ProviderScopes: map[string]string{"openai-codex": "account"}}); err != nil {
16+ t.Fatal(err)
17+ }
18+ info, err := os.Stat(path)
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if got := info.Mode().Perm(); got != 0o600 {
23+ t.Fatalf("cache permissions = %04o, want 0600", got)
24+ }
25+}
@@ -0,0 +1,25 @@
1+package provider
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "runtime"
7+ "testing"
8+)
9+
10+func TestSaveCacheUsesRestrictivePermissions(t *testing.T) {
11+ if runtime.GOOS == "windows" {
12+ t.Skip("Windows does not enforce POSIX file permissions")
13+ }
14+ path := filepath.Join(t.TempDir(), "models.json")
15+ if err := SaveCache(path, ModelCache{ProviderScopes: map[string]string{"openai-codex": "account"}}); err != nil {
16+ t.Fatal(err)
17+ }
18+ info, err := os.Stat(path)
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if got := info.Mode().Perm(); got != 0o600 {
23+ t.Fatalf("cache permissions = %04o, want 0600", got)
24+ }
25+}
M packages/provider/discover.go +86 -1
@@ -227,7 +227,92 @@ func looksLikeChatModel(id string) bool {
227227 return false
228228 }
229229
230-const openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
230+const (
231+ openaiCodexModelsBaseURL = "https://chatgpt.com/backend-api/codex"
232+ openaiCodexClientVersion = "0.144.0"
233+ openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
234+ maxDiscoveryResponseBytes = 10 << 20
235+)
236+
237+// DiscoverOpenAICodex lists the models available to a ChatGPT/Codex OAuth
238+// account. This catalog is separate from api.openai.com/v1/models and is the
239+// authoritative source for models usable through the Codex subscription route.
240+func DiscoverOpenAICodex(ctx context.Context, token, accountID, baseURL string) ([]Model, error) {
241+ if baseURL == "" {
242+ baseURL = openaiCodexModelsBaseURL
243+ }
244+ url := strings.TrimRight(baseURL, "/") + "/models?client_version=" + openaiCodexClientVersion
245+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
246+ if err != nil {
247+ return nil, err
248+ }
249+ req.Header.Set("authorization", "Bearer "+token)
250+ if accountID != "" {
251+ req.Header.Set("chatgpt-account-id", accountID)
252+ }
253+ // The Codex backend expects the same stable client identity used for
254+ // requests to its Responses endpoint.
255+ req.Header.Set("originator", "codex_cli_rs")
256+ req.Header.Set("user-agent", "codex_cli_rs/0.0.0")
257+
258+ client := &http.Client{Timeout: 15 * time.Second}
259+ resp, err := client.Do(req)
260+ if err != nil {
261+ return nil, err
262+ }
263+ defer resp.Body.Close()
264+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryResponseBytes+1))
265+ if err != nil {
266+ return nil, fmt.Errorf("openai-codex discover read response: %w", err)
267+ }
268+ if len(body) > maxDiscoveryResponseBytes {
269+ return nil, fmt.Errorf("openai-codex discover response exceeds %d bytes", maxDiscoveryResponseBytes)
270+ }
271+ if resp.StatusCode != http.StatusOK {
272+ return nil, fmt.Errorf("openai-codex discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
273+ }
274+
275+ var page struct {
276+ Models []struct {
277+ Slug string `json:"slug"`
278+ DisplayName string `json:"display_name"`
279+ SupportedInAPI bool `json:"supported_in_api"`
280+ Visibility string `json:"visibility"`
281+ SupportedReasoningLevels []string `json:"supported_reasoning_levels"`
282+ ContextWindow *int `json:"context_window"`
283+ MaxContext *int `json:"max_context_window"`
284+ } `json:"models"`
285+ }
286+ if err := json.Unmarshal(body, &page); err != nil {
287+ return nil, fmt.Errorf("openai-codex discover parse: %w", err)
288+ }
289+ out := make([]Model, 0, len(page.Models))
290+ for _, m := range page.Models {
291+ if m.Slug == "" || !m.SupportedInAPI || m.Visibility == "hide" {
292+ continue
293+ }
294+ contextWindow := 0
295+ if m.ContextWindow != nil {
296+ contextWindow = *m.ContextWindow
297+ }
298+ if contextWindow == 0 && m.MaxContext != nil {
299+ contextWindow = *m.MaxContext
300+ }
301+ displayName := m.DisplayName
302+ if displayName == "" {
303+ displayName = m.Slug
304+ }
305+ out = append(out, Model{
306+ Provider: "openai-codex",
307+ ID: m.Slug,
308+ DisplayName: displayName,
309+ ContextWindow: contextWindow,
310+ Reasoning: len(m.SupportedReasoningLevels) > 0,
311+ Source: "live",
312+ })
313+ }
314+ return out, nil
315+}
231316
232317 // DiscoverOpenRouter lists models from OpenRouter's public /models
233318 // endpoint (no auth). Per-token USD prices are converted to USD per 1M
@@ -227,7 +227,92 @@ func looksLikeChatModel(id string) bool {
227 return false227 return false
228 }228 }
229 229
230-const openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"230+const (
231+ openaiCodexModelsBaseURL = "https://chatgpt.com/backend-api/codex"
232+ openaiCodexClientVersion = "0.144.0"
233+ openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
234+ maxDiscoveryResponseBytes = 10 << 20
235+)
236+
237+// DiscoverOpenAICodex lists the models available to a ChatGPT/Codex OAuth
238+// account. This catalog is separate from api.openai.com/v1/models and is the
239+// authoritative source for models usable through the Codex subscription route.
240+func DiscoverOpenAICodex(ctx context.Context, token, accountID, baseURL string) ([]Model, error) {
241+ if baseURL == "" {
242+ baseURL = openaiCodexModelsBaseURL
243+ }
244+ url := strings.TrimRight(baseURL, "/") + "/models?client_version=" + openaiCodexClientVersion
245+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
246+ if err != nil {
247+ return nil, err
248+ }
249+ req.Header.Set("authorization", "Bearer "+token)
250+ if accountID != "" {
251+ req.Header.Set("chatgpt-account-id", accountID)
252+ }
253+ // The Codex backend expects the same stable client identity used for
254+ // requests to its Responses endpoint.
255+ req.Header.Set("originator", "codex_cli_rs")
256+ req.Header.Set("user-agent", "codex_cli_rs/0.0.0")
257+
258+ client := &http.Client{Timeout: 15 * time.Second}
259+ resp, err := client.Do(req)
260+ if err != nil {
261+ return nil, err
262+ }
263+ defer resp.Body.Close()
264+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryResponseBytes+1))
265+ if err != nil {
266+ return nil, fmt.Errorf("openai-codex discover read response: %w", err)
267+ }
268+ if len(body) > maxDiscoveryResponseBytes {
269+ return nil, fmt.Errorf("openai-codex discover response exceeds %d bytes", maxDiscoveryResponseBytes)
270+ }
271+ if resp.StatusCode != http.StatusOK {
272+ return nil, fmt.Errorf("openai-codex discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
273+ }
274+
275+ var page struct {
276+ Models []struct {
277+ Slug string `json:"slug"`
278+ DisplayName string `json:"display_name"`
279+ SupportedInAPI bool `json:"supported_in_api"`
280+ Visibility string `json:"visibility"`
281+ SupportedReasoningLevels []string `json:"supported_reasoning_levels"`
282+ ContextWindow *int `json:"context_window"`
283+ MaxContext *int `json:"max_context_window"`
284+ } `json:"models"`
285+ }
286+ if err := json.Unmarshal(body, &page); err != nil {
287+ return nil, fmt.Errorf("openai-codex discover parse: %w", err)
288+ }
289+ out := make([]Model, 0, len(page.Models))
290+ for _, m := range page.Models {
291+ if m.Slug == "" || !m.SupportedInAPI || m.Visibility == "hide" {
292+ continue
293+ }
294+ contextWindow := 0
295+ if m.ContextWindow != nil {
296+ contextWindow = *m.ContextWindow
297+ }
298+ if contextWindow == 0 && m.MaxContext != nil {
299+ contextWindow = *m.MaxContext
300+ }
301+ displayName := m.DisplayName
302+ if displayName == "" {
303+ displayName = m.Slug
304+ }
305+ out = append(out, Model{
306+ Provider: "openai-codex",
307+ ID: m.Slug,
308+ DisplayName: displayName,
309+ ContextWindow: contextWindow,
310+ Reasoning: len(m.SupportedReasoningLevels) > 0,
311+ Source: "live",
312+ })
313+ }
314+ return out, nil
315+}
231 316
232 // DiscoverOpenRouter lists models from OpenRouter's public /models317 // DiscoverOpenRouter lists models from OpenRouter's public /models
233 // endpoint (no auth). Per-token USD prices are converted to USD per 1M318 // endpoint (no auth). Per-token USD prices are converted to USD per 1M
A packages/provider/discover_test.go +61 -0
@@ -0,0 +1,61 @@
1+package provider
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+)
10+
11+func TestDiscoverOpenAICodex(t *testing.T) {
12+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13+ if r.URL.Path != "/models" {
14+ t.Errorf("path = %q, want /models", r.URL.Path)
15+ }
16+ if got := r.URL.Query().Get("client_version"); got != openaiCodexClientVersion {
17+ t.Errorf("client_version = %q, want %q", got, openaiCodexClientVersion)
18+ }
19+ if got := r.Header.Get("authorization"); got != "Bearer token" {
20+ t.Errorf("authorization = %q", got)
21+ }
22+ if got := r.Header.Get("chatgpt-account-id"); got != "account" {
23+ t.Errorf("chatgpt-account-id = %q", got)
24+ }
25+ if got := r.Header.Get("originator"); got != "codex_cli_rs" {
26+ t.Errorf("originator = %q", got)
27+ }
28+ _, _ = io.WriteString(w, `{"models":[
29+ {"slug":"gpt-5.6-luna","display_name":"GPT-5.6 Luna","supported_in_api":true,"context_window":272000,"max_context_window":400000,"supported_reasoning_levels":["low","medium","high"]},
30+ {"slug":"gpt-hidden","display_name":"Hidden","supported_in_api":true,"visibility":"hide"},
31+ {"slug":"gpt-unsupported","display_name":"Unsupported","supported_in_api":false},
32+ {"slug":"gpt-custom","supported_in_api":true,"max_context_window":100000}
33+ ]}`)
34+ }))
35+ defer srv.Close()
36+
37+ got, err := DiscoverOpenAICodex(context.Background(), "token", "account", srv.URL)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ if len(got) != 2 {
42+ t.Fatalf("models = %+v, want 2 models", got)
43+ }
44+ if got[0].Provider != "openai-codex" || got[0].ID != "gpt-5.6-luna" || got[0].DisplayName != "GPT-5.6 Luna" || got[0].ContextWindow != 272000 || !got[0].Reasoning || got[0].Source != "live" {
45+ t.Errorf("first model = %+v", got[0])
46+ }
47+ if got[1].ID != "gpt-custom" || got[1].DisplayName != "gpt-custom" || got[1].ContextWindow != 100000 {
48+ t.Errorf("second model = %+v", got[1])
49+ }
50+}
51+
52+func TestDiscoverOpenAICodexRejectsHTTPError(t *testing.T) {
53+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54+ http.Error(w, "denied", http.StatusUnauthorized)
55+ }))
56+ defer srv.Close()
57+
58+ if _, err := DiscoverOpenAICodex(context.Background(), "token", "", srv.URL); err == nil {
59+ t.Fatal("DiscoverOpenAICodex error = nil, want HTTP error")
60+ }
61+}
@@ -0,0 +1,61 @@
1+package provider
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+)
10+
11+func TestDiscoverOpenAICodex(t *testing.T) {
12+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13+ if r.URL.Path != "/models" {
14+ t.Errorf("path = %q, want /models", r.URL.Path)
15+ }
16+ if got := r.URL.Query().Get("client_version"); got != openaiCodexClientVersion {
17+ t.Errorf("client_version = %q, want %q", got, openaiCodexClientVersion)
18+ }
19+ if got := r.Header.Get("authorization"); got != "Bearer token" {
20+ t.Errorf("authorization = %q", got)
21+ }
22+ if got := r.Header.Get("chatgpt-account-id"); got != "account" {
23+ t.Errorf("chatgpt-account-id = %q", got)
24+ }
25+ if got := r.Header.Get("originator"); got != "codex_cli_rs" {
26+ t.Errorf("originator = %q", got)
27+ }
28+ _, _ = io.WriteString(w, `{"models":[
29+ {"slug":"gpt-5.6-luna","display_name":"GPT-5.6 Luna","supported_in_api":true,"context_window":272000,"max_context_window":400000,"supported_reasoning_levels":["low","medium","high"]},
30+ {"slug":"gpt-hidden","display_name":"Hidden","supported_in_api":true,"visibility":"hide"},
31+ {"slug":"gpt-unsupported","display_name":"Unsupported","supported_in_api":false},
32+ {"slug":"gpt-custom","supported_in_api":true,"max_context_window":100000}
33+ ]}`)
34+ }))
35+ defer srv.Close()
36+
37+ got, err := DiscoverOpenAICodex(context.Background(), "token", "account", srv.URL)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ if len(got) != 2 {
42+ t.Fatalf("models = %+v, want 2 models", got)
43+ }
44+ if got[0].Provider != "openai-codex" || got[0].ID != "gpt-5.6-luna" || got[0].DisplayName != "GPT-5.6 Luna" || got[0].ContextWindow != 272000 || !got[0].Reasoning || got[0].Source != "live" {
45+ t.Errorf("first model = %+v", got[0])
46+ }
47+ if got[1].ID != "gpt-custom" || got[1].DisplayName != "gpt-custom" || got[1].ContextWindow != 100000 {
48+ t.Errorf("second model = %+v", got[1])
49+ }
50+}
51+
52+func TestDiscoverOpenAICodexRejectsHTTPError(t *testing.T) {
53+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54+ http.Error(w, "denied", http.StatusUnauthorized)
55+ }))
56+ defer srv.Close()
57+
58+ if _, err := DiscoverOpenAICodex(context.Background(), "token", "", srv.URL); err == nil {
59+ t.Fatal("DiscoverOpenAICodex error = nil, want HTTP error")
60+ }
61+}
M packages/provider/merge.go +24 -8
@@ -8,7 +8,19 @@ package provider
88 // with placeholder prices so they still render in the picker. Prices
99 // can be populated later from a richer catalog source.
1010 func MergeCatalog(live []Model) []Model {
11+ return MergeCatalogForProviders(live, nil)
12+}
13+
14+// MergeCatalogForProviders merges live entries and omits baked-in models for
15+// providers whose live catalog is authoritative. This is needed for
16+// account-scoped catalogs, where a static fallback could otherwise expose a
17+// model the signed-in account cannot use.
18+func MergeCatalogForProviders(live []Model, authoritativeProviders []string) []Model {
1119 byKey := func(p, id string) string { return p + "/" + id }
20+ authoritative := make(map[string]bool, len(authoritativeProviders))
21+ for _, name := range authoritativeProviders {
22+ authoritative[name] = true
23+ }
1224
1325 staticIndex := make(map[string]Model, len(Catalog))
1426 staticOrder := make([]string, 0, len(Catalog))
@@ -16,26 +28,30 @@ func MergeCatalog(live []Model) []Model {
1628 m.Source = "catalog"
1729 k := byKey(m.Provider, m.ID)
1830 staticIndex[k] = m
19- staticOrder = append(staticOrder, k)
20- }
21-
22- seenLive := make(map[string]bool, len(live))
23- for _, m := range live {
24- seenLive[byKey(m.Provider, m.ID)] = true
31+ if !authoritative[m.Provider] {
32+ staticOrder = append(staticOrder, k)
33+ }
2534 }
2635
2736 // Promote/overwrite from live.
2837 for _, l := range live {
2938 k := byKey(l.Provider, l.ID)
3039 if s, ok := staticIndex[k]; ok {
31- // Keep static prices & context window (live endpoint rarely
32- // exposes these), but mark as live and non-speculative.
40+ // Preserve static pricing and output limits while allowing live
41+ // discovery to supply the account's current display and context
42+ // metadata.
3343 s.Source = "live"
3444 s.Speculative = false
3545 if l.DisplayName != "" {
3646 s.DisplayName = l.DisplayName
3747 }
48+ if l.ContextWindow > 0 {
49+ s.ContextWindow = l.ContextWindow
50+ }
3851 staticIndex[k] = s
52+ if authoritative[l.Provider] {
53+ staticOrder = append(staticOrder, k)
54+ }
3955 } else {
4056 // New live id we'd never heard of. Best-effort defaults.
4157 if l.DisplayName == "" {
@@ -8,7 +8,19 @@ package provider
8 // with placeholder prices so they still render in the picker. Prices8 // with placeholder prices so they still render in the picker. Prices
9 // can be populated later from a richer catalog source.9 // can be populated later from a richer catalog source.
10 func MergeCatalog(live []Model) []Model {10 func MergeCatalog(live []Model) []Model {
11+ return MergeCatalogForProviders(live, nil)
12+}
13+
14+// MergeCatalogForProviders merges live entries and omits baked-in models for
15+// providers whose live catalog is authoritative. This is needed for
16+// account-scoped catalogs, where a static fallback could otherwise expose a
17+// model the signed-in account cannot use.
18+func MergeCatalogForProviders(live []Model, authoritativeProviders []string) []Model {
11 byKey := func(p, id string) string { return p + "/" + id }19 byKey := func(p, id string) string { return p + "/" + id }
20+ authoritative := make(map[string]bool, len(authoritativeProviders))
21+ for _, name := range authoritativeProviders {
22+ authoritative[name] = true
23+ }
12 24
13 staticIndex := make(map[string]Model, len(Catalog))25 staticIndex := make(map[string]Model, len(Catalog))
14 staticOrder := make([]string, 0, len(Catalog))26 staticOrder := make([]string, 0, len(Catalog))
@@ -16,26 +28,30 @@ func MergeCatalog(live []Model) []Model {
16 m.Source = "catalog"28 m.Source = "catalog"
17 k := byKey(m.Provider, m.ID)29 k := byKey(m.Provider, m.ID)
18 staticIndex[k] = m30 staticIndex[k] = m
19- staticOrder = append(staticOrder, k)31+ if !authoritative[m.Provider] {
20- }32+ staticOrder = append(staticOrder, k)
21-33+ }
22- seenLive := make(map[string]bool, len(live))
23- for _, m := range live {
24- seenLive[byKey(m.Provider, m.ID)] = true
25 }34 }
26 35
27 // Promote/overwrite from live.36 // Promote/overwrite from live.
28 for _, l := range live {37 for _, l := range live {
29 k := byKey(l.Provider, l.ID)38 k := byKey(l.Provider, l.ID)
30 if s, ok := staticIndex[k]; ok {39 if s, ok := staticIndex[k]; ok {
31- // Keep static prices & context window (live endpoint rarely40+ // Preserve static pricing and output limits while allowing live
32- // exposes these), but mark as live and non-speculative.41+ // discovery to supply the account's current display and context
42+ // metadata.
33 s.Source = "live"43 s.Source = "live"
34 s.Speculative = false44 s.Speculative = false
35 if l.DisplayName != "" {45 if l.DisplayName != "" {
36 s.DisplayName = l.DisplayName46 s.DisplayName = l.DisplayName
37 }47 }
48+ if l.ContextWindow > 0 {
49+ s.ContextWindow = l.ContextWindow
50+ }
38 staticIndex[k] = s51 staticIndex[k] = s
52+ if authoritative[l.Provider] {
53+ staticOrder = append(staticOrder, k)
54+ }
39 } else {55 } else {
40 // New live id we'd never heard of. Best-effort defaults.56 // New live id we'd never heard of. Best-effort defaults.
41 if l.DisplayName == "" {57 if l.DisplayName == "" {
A packages/provider/merge_test.go +82 -0
@@ -0,0 +1,82 @@
1+package provider
2+
3+import "testing"
4+
5+func TestMergeCatalogForProvidersPrunesUnavailableStaticModels(t *testing.T) {
6+ const (
7+ providerID = "openai-codex"
8+ liveID = "gpt-5.6-luna"
9+ absentID = "gpt-5.6-sol"
10+ )
11+
12+ staticPresent := false
13+ for _, model := range Catalog {
14+ if model.Provider == providerID && model.ID == absentID {
15+ staticPresent = true
16+ break
17+ }
18+ }
19+ if !staticPresent {
20+ t.Fatalf("fixture drift: static model %s/%s is no longer in Catalog", providerID, absentID)
21+ }
22+
23+ merged := MergeCatalogForProviders([]Model{{
24+ Provider: providerID,
25+ ID: liveID,
26+ Source: "live",
27+ }}, []string{providerID})
28+
29+ foundLive := false
30+ for _, model := range merged {
31+ if model.Provider != providerID {
32+ continue
33+ }
34+ if model.ID == absentID {
35+ t.Fatalf("unavailable static model %s/%s remains in authoritative catalog", providerID, absentID)
36+ }
37+ if model.ID == liveID {
38+ foundLive = true
39+ }
40+ }
41+ if !foundLive {
42+ t.Fatalf("live model %s/%s missing from authoritative catalog", providerID, liveID)
43+ }
44+}
45+
46+func TestMergeCatalogForProvidersPreservesStaticMetadata(t *testing.T) {
47+ merged := MergeCatalogForProviders([]Model{{
48+ Provider: "openai-codex",
49+ ID: "gpt-5.6-luna",
50+ Source: "live",
51+ }}, []string{"openai-codex"})
52+ for _, model := range merged {
53+ if model.Provider == "openai-codex" && model.ID == "gpt-5.6-luna" {
54+ if model.ContextWindow == 0 || model.MaxOutput == 0 || model.PriceInput == 0 || model.PriceOutput == 0 {
55+ t.Fatalf("live model did not retain static metadata: %+v", model)
56+ }
57+ return
58+ }
59+ }
60+ t.Fatal("live Codex model missing from merged catalog")
61+}
62+
63+func TestSetLiveModelsForProvidersPrunesEmptyAuthoritativeCatalog(t *testing.T) {
64+ if _, err := FindModel("openai-codex", "gpt-5.6-luna"); err != nil {
65+ t.Fatalf("fixture drift: static Codex model missing: %v", err)
66+ }
67+ activeMu.RLock()
68+ previousActive := active
69+ previousSet := activeSet
70+ activeMu.RUnlock()
71+ t.Cleanup(func() {
72+ activeMu.Lock()
73+ active = previousActive
74+ activeSet = previousSet
75+ activeMu.Unlock()
76+ })
77+
78+ SetLiveModelsForProviders(nil, []string{"openai-codex"})
79+ if _, err := FindModel("openai-codex", "gpt-5.6-luna"); err == nil {
80+ t.Fatal("empty authoritative catalog retained a static Codex model")
81+ }
82+}
@@ -0,0 +1,82 @@
1+package provider
2+
3+import "testing"
4+
5+func TestMergeCatalogForProvidersPrunesUnavailableStaticModels(t *testing.T) {
6+ const (
7+ providerID = "openai-codex"
8+ liveID = "gpt-5.6-luna"
9+ absentID = "gpt-5.6-sol"
10+ )
11+
12+ staticPresent := false
13+ for _, model := range Catalog {
14+ if model.Provider == providerID && model.ID == absentID {
15+ staticPresent = true
16+ break
17+ }
18+ }
19+ if !staticPresent {
20+ t.Fatalf("fixture drift: static model %s/%s is no longer in Catalog", providerID, absentID)
21+ }
22+
23+ merged := MergeCatalogForProviders([]Model{{
24+ Provider: providerID,
25+ ID: liveID,
26+ Source: "live",
27+ }}, []string{providerID})
28+
29+ foundLive := false
30+ for _, model := range merged {
31+ if model.Provider != providerID {
32+ continue
33+ }
34+ if model.ID == absentID {
35+ t.Fatalf("unavailable static model %s/%s remains in authoritative catalog", providerID, absentID)
36+ }
37+ if model.ID == liveID {
38+ foundLive = true
39+ }
40+ }
41+ if !foundLive {
42+ t.Fatalf("live model %s/%s missing from authoritative catalog", providerID, liveID)
43+ }
44+}
45+
46+func TestMergeCatalogForProvidersPreservesStaticMetadata(t *testing.T) {
47+ merged := MergeCatalogForProviders([]Model{{
48+ Provider: "openai-codex",
49+ ID: "gpt-5.6-luna",
50+ Source: "live",
51+ }}, []string{"openai-codex"})
52+ for _, model := range merged {
53+ if model.Provider == "openai-codex" && model.ID == "gpt-5.6-luna" {
54+ if model.ContextWindow == 0 || model.MaxOutput == 0 || model.PriceInput == 0 || model.PriceOutput == 0 {
55+ t.Fatalf("live model did not retain static metadata: %+v", model)
56+ }
57+ return
58+ }
59+ }
60+ t.Fatal("live Codex model missing from merged catalog")
61+}
62+
63+func TestSetLiveModelsForProvidersPrunesEmptyAuthoritativeCatalog(t *testing.T) {
64+ if _, err := FindModel("openai-codex", "gpt-5.6-luna"); err != nil {
65+ t.Fatalf("fixture drift: static Codex model missing: %v", err)
66+ }
67+ activeMu.RLock()
68+ previousActive := active
69+ previousSet := activeSet
70+ activeMu.RUnlock()
71+ t.Cleanup(func() {
72+ activeMu.Lock()
73+ active = previousActive
74+ activeSet = previousSet
75+ activeMu.Unlock()
76+ })
77+
78+ SetLiveModelsForProviders(nil, []string{"openai-codex"})
79+ if _, err := FindModel("openai-codex", "gpt-5.6-luna"); err == nil {
80+ t.Fatal("empty authoritative catalog retained a static Codex model")
81+ }
82+}
M packages/provider/models.go +9 -5
@@ -385,19 +385,16 @@ var Catalog = []Model{
385385 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
386386 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
387387 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
388- Speculative: true,
389388 },
390389 {
391390 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
392391 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
393392 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
394- Speculative: true,
395393 },
396394 {
397395 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
398396 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
399397 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
400- Speculative: true,
401398 },
402399 }
403400
@@ -421,14 +418,21 @@ var (
421418 // Typically called after a successful /v1/models discovery or on load
422419 // from the on-disk cache.
423420 func SetLiveModels(live []Model) {
421+ SetLiveModelsForProviders(live, nil)
422+}
423+
424+// SetLiveModelsForProviders replaces the live overlay, treating the listed
425+// providers' live catalogs as authoritative. For those providers, static
426+// entries omitted by discovery are removed from the active catalog.
427+func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
424428 activeMu.Lock()
425429 defer activeMu.Unlock()
426430 activeSet = true
427- if len(live) == 0 {
431+ if len(live) == 0 && len(authoritativeProviders) == 0 {
428432 active = nil
429433 return
430434 }
431- active = MergeCatalog(live)
435+ active = MergeCatalogForProviders(live, authoritativeProviders)
432436 }
433437
434438 // Active returns the current merged catalog.
@@ -385,19 +385,16 @@ var Catalog = []Model{
385 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",385 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
386 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,386 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
387 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,387 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
388- Speculative: true,
389 },388 },
390 {389 {
391 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",390 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
392 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,391 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
393 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,392 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
394- Speculative: true,
395 },393 },
396 {394 {
397 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",395 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
398 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,396 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
399 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,397 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
400- Speculative: true,
401 },398 },
402 }399 }
403 400
@@ -421,14 +418,21 @@ var (
421 // Typically called after a successful /v1/models discovery or on load418 // Typically called after a successful /v1/models discovery or on load
422 // from the on-disk cache.419 // from the on-disk cache.
423 func SetLiveModels(live []Model) {420 func SetLiveModels(live []Model) {
421+ SetLiveModelsForProviders(live, nil)
422+}
423+
424+// SetLiveModelsForProviders replaces the live overlay, treating the listed
425+// providers' live catalogs as authoritative. For those providers, static
426+// entries omitted by discovery are removed from the active catalog.
427+func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
424 activeMu.Lock()428 activeMu.Lock()
425 defer activeMu.Unlock()429 defer activeMu.Unlock()
426 activeSet = true430 activeSet = true
427- if len(live) == 0 {431+ if len(live) == 0 && len(authoritativeProviders) == 0 {
428 active = nil432 active = nil
429 return433 return
430 }434 }
431- active = MergeCatalog(live)435+ active = MergeCatalogForProviders(live, authoritativeProviders)
432 }436 }
433 437
434 // Active returns the current merged catalog.438 // Active returns the current merged catalog.
M packages/provider/provider_test.go +3 -0
@@ -643,6 +643,9 @@ func TestGPT56CatalogEntries(t *testing.T) {
643643 if m.PriceInput != tc.priceInput || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead || m.PriceCacheWrite != tc.cacheWrite {
644644 t.Fatalf("%s/%s prices: %+v", tc.provider, tc.id, m)
645645 }
646+ if tc.provider == "openai-codex" && m.Speculative {
647+ t.Fatalf("%s/%s is speculative", tc.provider, tc.id)
648+ }
646649 }
647650 }
648651
@@ -643,6 +643,9 @@ func TestGPT56CatalogEntries(t *testing.T) {
643 if m.PriceInput != tc.priceInput || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead || m.PriceCacheWrite != tc.cacheWrite {643 if m.PriceInput != tc.priceInput || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead || m.PriceCacheWrite != tc.cacheWrite {
644 t.Fatalf("%s/%s prices: %+v", tc.provider, tc.id, m)644 t.Fatalf("%s/%s prices: %+v", tc.provider, tc.id, m)
645 }645 }
646+ if tc.provider == "openai-codex" && m.Speculative {
647+ t.Fatalf("%s/%s is speculative", tc.provider, tc.id)
648+ }
646 }649 }
647 }650 }
648 651

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