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 Unverified
B5690EEEBB952194The 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 | |||
| 601 | 601 | return key, "apikey", "", nil | |
| 602 | 602 | } | |
| 603 | 603 | if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" { | |
| 604 | - | tok, _ := refreshIfExpired(provider, pc.OAuth) | |
| 604 | + | tok, _ := refreshIfExpiredContext(ctx, provider, pc.OAuth) | |
| 605 | 605 | return tok.AccessToken, "oauth", "", nil | |
| 606 | 606 | } | |
| 607 | 607 | } | |
| 608 | 608 | switch provider { | |
| 609 | 609 | case "anthropic": | |
| 610 | 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 | 612 | return tok.AccessToken, "oauth", "", nil | |
| 613 | 613 | } | |
| 614 | 614 | case "openai-codex": | |
| 615 | 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 | 617 | return tok.AccessToken, "oauth", tok.AccountID, nil | |
| 618 | 618 | } | |
| 619 | 619 | case "kimi": | |
| 620 | 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 | 622 | return tok.AccessToken, "oauth", "", nil | |
| 623 | 623 | } | |
| 624 | 624 | if kimiCLIFallbackDisabled() { | |
| 625 | 625 | break | |
| 626 | 626 | } | |
| 627 | 627 | if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" { | |
| 628 | - | tok, _ = refreshIfExpired("kimi", tok) | |
| 628 | + | tok, _ = refreshIfExpiredContext(ctx, "kimi", tok) | |
| 629 | 629 | return tok.AccessToken, "oauth", "", nil | |
| 630 | 630 | } | |
| 631 | 631 | case "github-copilot": | |
| @@ -794,6 +794,12 @@ func loadOAuthToken(providerName string) *auth.OAuthToken { | |||
| 794 | 794 | // a request with the stale access_token, which will 401. That's still | |
| 795 | 795 | // better than crashing at credential-resolution time. | |
| 796 | 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 | 803 | if tok == nil { | |
| 798 | 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 | 813 | if providerName == "xai" { | |
| 808 | - | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 814 | + | ctx, cancel := context.WithTimeout(parent, 30*time.Second) | |
| 809 | 815 | defer cancel() | |
| 810 | 816 | next, err := auth.RefreshXAIToken(ctx, tok.RefreshToken) | |
| 811 | 817 | if err != nil { | |
| @@ -832,7 +838,7 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok | |||
| 832 | 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 | 842 | defer cancel() | |
| 837 | 843 | next, err := op.Refresh(ctx, tok.RefreshToken) | |
| 838 | 844 | if err != nil { | |
| @@ -601,31 +601,31 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma | |||||
| 601 | return key, "apikey", "", nil | 601 | 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", "", nil | 605 | 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", "", nil | 612 | 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, nil | 617 | 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", "", nil | 622 | return tok.AccessToken, "oauth", "", nil | ||
| 623 | } | 623 | } | ||
| 624 | if kimiCLIFallbackDisabled() { | 624 | if kimiCLIFallbackDisabled() { | ||
| 625 | break | 625 | 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", "", nil | 629 | 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 still | 794 | // 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() { | |||
| 29 | 29 | if err != nil { | |
| 30 | 30 | return | |
| 31 | 31 | } | |
| 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) | |
| 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 | 114 | // LoadUserModels reads $ZUT_HOME/models.json and merges any user-defined | |
| 38 | 115 | // models into the active catalog. User models take highest precedence. | |
| 39 | 116 | // 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 | 252 | func refreshModels() { | |
| 176 | 253 | cached, _ := provider.LoadCache(ModelCachePath()) | |
| 177 | - | if cached.IsFresh() { | |
| 254 | + | currentScopes := currentModelProviderScopes() | |
| 255 | + | if cached.IsFresh() && providerScopesEqual(cached.ProviderScopes, currentScopes) { | |
| 178 | 256 | return | |
| 179 | 257 | } | |
| 180 | 258 | ||
| @@ -182,6 +260,16 @@ func refreshModels() { | |||
| 182 | 260 | defer cancel() | |
| 183 | 261 | ||
| 184 | 262 | 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 | 274 | if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" { | |
| 187 | 275 | // /v1/models on Anthropic is API-key only; OAuth tokens can | |
| @@ -196,6 +284,18 @@ func refreshModels() { | |||
| 196 | 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 | 299 | if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" { | |
| 200 | 300 | if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil { | |
| 201 | 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 | 322 | return | |
| 223 | 323 | } | |
| 224 | - | provider.SetLiveModels(all) | |
| 324 | + | provider.SetLiveModelsForProviders(all, authoritativeProviders) | |
| 225 | 325 | _ = 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, | |
| 228 | 330 | }) | |
| 229 | 331 | } | |
| @@ -29,11 +29,88 @@ func LoadCachedModels() { | |||||
| 29 | if err != nil { | 29 | if err != nil { | ||
| 30 | return | 30 | 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-defined | 114 | // 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, malformed | 116 | // 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 | return | 256 | return | ||
| 179 | } | 257 | } | ||
| 180 | 258 | ||||
| @@ -182,6 +260,16 @@ func refreshModels() { | |||||
| 182 | defer cancel() | 260 | defer cancel() | ||
| 183 | 261 | ||||
| 184 | var all []provider.Model | 262 | 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 can | 275 | // /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 | return | 322 | 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 ( | |||
| 13 | 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 | 63 | func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) { | |
| 17 | 64 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 18 | 65 | 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | // ModelCache is the on-disk shape for discovered models. | |
| 12 | 12 | 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"` | |
| 15 | 17 | } | |
| 16 | 18 | ||
| 17 | 19 | // CacheTTL is how long a discovered list is considered fresh. | |
| @@ -49,7 +51,7 @@ func SaveCache(path string, c ModelCache) error { | |||
| 49 | 51 | return err | |
| 50 | 52 | } | |
| 51 | 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 | 55 | return err | |
| 54 | 56 | } | |
| 55 | 57 | 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 err | 51 | 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 err | 55 | 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 { | |||
| 227 | 227 | 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 | 317 | // DiscoverOpenRouter lists models from OpenRouter's public /models | |
| 233 | 318 | // endpoint (no auth). Per-token USD prices are converted to USD per 1M | |
| @@ -227,7 +227,92 @@ func looksLikeChatModel(id string) bool { | |||||
| 227 | return false | 227 | 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 /models | 317 | // DiscoverOpenRouter lists models from OpenRouter's public /models | ||
| 233 | // endpoint (no auth). Per-token USD prices are converted to USD per 1M | 318 | // 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 | |||
| 8 | 8 | // with placeholder prices so they still render in the picker. Prices | |
| 9 | 9 | // can be populated later from a richer catalog source. | |
| 10 | 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 | 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 | 25 | staticIndex := make(map[string]Model, len(Catalog)) | |
| 14 | 26 | staticOrder := make([]string, 0, len(Catalog)) | |
| @@ -16,26 +28,30 @@ func MergeCatalog(live []Model) []Model { | |||
| 16 | 28 | m.Source = "catalog" | |
| 17 | 29 | k := byKey(m.Provider, m.ID) | |
| 18 | 30 | 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 | + | } | |
| 25 | 34 | } | |
| 26 | 35 | ||
| 27 | 36 | // Promote/overwrite from live. | |
| 28 | 37 | for _, l := range live { | |
| 29 | 38 | k := byKey(l.Provider, l.ID) | |
| 30 | 39 | 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. | |
| 33 | 43 | s.Source = "live" | |
| 34 | 44 | s.Speculative = false | |
| 35 | 45 | if l.DisplayName != "" { | |
| 36 | 46 | s.DisplayName = l.DisplayName | |
| 37 | 47 | } | |
| 48 | + | if l.ContextWindow > 0 { | |
| 49 | + | s.ContextWindow = l.ContextWindow | |
| 50 | + | } | |
| 38 | 51 | staticIndex[k] = s | |
| 52 | + | if authoritative[l.Provider] { | |
| 53 | + | staticOrder = append(staticOrder, k) | |
| 54 | + | } | |
| 39 | 55 | } else { | |
| 40 | 56 | // New live id we'd never heard of. Best-effort defaults. | |
| 41 | 57 | if l.DisplayName == "" { | |
| @@ -8,7 +8,19 @@ package provider | |||||
| 8 | // with placeholder prices so they still render in the picker. Prices | 8 | // 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] = m | 30 | 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 rarely | 40 | + | // 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 = false | 44 | s.Speculative = false | ||
| 35 | if l.DisplayName != "" { | 45 | if l.DisplayName != "" { | ||
| 36 | s.DisplayName = l.DisplayName | 46 | s.DisplayName = l.DisplayName | ||
| 37 | } | 47 | } | ||
| 48 | + | if l.ContextWindow > 0 { | |||
| 49 | + | s.ContextWindow = l.ContextWindow | |||
| 50 | + | } | |||
| 38 | staticIndex[k] = s | 51 | 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{ | |||
| 385 | 385 | Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", | |
| 386 | 386 | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 387 | 387 | PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, | |
| 388 | - | Speculative: true, | |
| 389 | 388 | }, | |
| 390 | 389 | { | |
| 391 | 390 | Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", | |
| 392 | 391 | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 393 | 392 | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, | |
| 394 | - | Speculative: true, | |
| 395 | 393 | }, | |
| 396 | 394 | { | |
| 397 | 395 | Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", | |
| 398 | 396 | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 399 | 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 | 418 | // Typically called after a successful /v1/models discovery or on load | |
| 422 | 419 | // from the on-disk cache. | |
| 423 | 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 | 428 | activeMu.Lock() | |
| 425 | 429 | defer activeMu.Unlock() | |
| 426 | 430 | activeSet = true | |
| 427 | - | if len(live) == 0 { | |
| 431 | + | if len(live) == 0 && len(authoritativeProviders) == 0 { | |
| 428 | 432 | active = nil | |
| 429 | 433 | return | |
| 430 | 434 | } | |
| 431 | - | active = MergeCatalog(live) | |
| 435 | + | active = MergeCatalogForProviders(live, authoritativeProviders) | |
| 432 | 436 | } | |
| 433 | 437 | ||
| 434 | 438 | // 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 load | 418 | // 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 = true | 430 | activeSet = true | ||
| 427 | - | if len(live) == 0 { | 431 | + | if len(live) == 0 && len(authoritativeProviders) == 0 { |
| 428 | active = nil | 432 | active = nil | ||
| 429 | return | 433 | 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) { | |||
| 643 | 643 | if m.PriceInput != tc.priceInput || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead || m.PriceCacheWrite != tc.cacheWrite { | |
| 644 | 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 | ||
| @@ -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 | ||||