package service import ( "context" "encoding/json" "errors" "io" "net/http" "strings" "testing" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) type upstreamModelMetadataRepoStub struct { AccountRepository accountID int64 updates map[string]any err error } func headerValuesEqualFold(header http.Header, name string) []string { for key, values := range header { if strings.EqualFold(key, name) { return values } } return nil } func (r *upstreamModelMetadataRepoStub) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { r.accountID = id r.updates = updates return r.err } func upstreamModelSyncTestConfig() *config.Config { return &config.Config{ Security: config.SecurityConfig{ URLAllowlist: config.URLAllowlistConfig{Enabled: false}, }, } } func grokOAuthModelSyncTestAccount(baseURL string) *Account { credentials := map[string]any{ "access_token": "oauth-access-token", "refresh_token": "oauth-refresh-token", "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), "sub": "grok-user-id", "email": "grok-user@example.com", } if strings.TrimSpace(baseURL) != "" { credentials["base_url"] = baseURL } return &Account{ ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: credentials, } } func TestBuildV1ModelsURL(t *testing.T) { t.Parallel() require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com")) require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com/v1")) require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com/v1/models")) require.Equal(t, "https://gateway.example.com/antigravity/v1/models", buildV1ModelsURL("https://gateway.example.com/antigravity/")) } func TestBuildOpenAIModelsURL(t *testing.T) { t.Parallel() tests := []struct { name string base string want string }{ { name: "zhipu v4 coding base url", base: "https://open.bigmodel.cn/api/coding/paas/v4", want: "https://open.bigmodel.cn/api/coding/paas/v4/models", }, { name: "openai v1 base url", base: "https://api.openai.com/v1", want: "https://api.openai.com/v1/models", }, { name: "models url unchanged", base: "https://api.openai.com/v1/models", want: "https://api.openai.com/v1/models", }, { name: "host fallback uses v1", base: "https://api.openai.com", want: "https://api.openai.com/v1/models", }, { name: "trailing slash on v4", base: "https://open.bigmodel.cn/api/coding/paas/v4/", want: "https://open.bigmodel.cn/api/coding/paas/v4/models", }, { name: "v2 base url", base: "https://gateway.example.com/openai/v2", want: "https://gateway.example.com/openai/v2/models", }, { name: "v3 base url", base: "https://gateway.example.com/openai/v3", want: "https://gateway.example.com/openai/v3/models", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() require.Equal(t, tt.want, buildOpenAIModelsURL(tt.base)) }) } } func TestBuildGeminiModelsURL(t *testing.T) { t.Parallel() require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", buildGeminiModelsURL("https://generativelanguage.googleapis.com")) require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", buildGeminiModelsURL("https://generativelanguage.googleapis.com/v1beta")) require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", buildGeminiModelsURL("https://generativelanguage.googleapis.com/v1beta/models")) } func TestExtractUpstreamModelIDs(t *testing.T) { t.Parallel() tests := []struct { name string body string want []string }{ { name: "openai and anthropic data array", body: `{"data":[{"id":"claude-sonnet-4-5"},{"id":"gpt-5"},{"id":"gpt-5"},{"id":""}]}`, want: []string{"claude-sonnet-4-5", "gpt-5"}, }, { name: "gemini models array strips prefix", body: `{"models":[{"name":"models/gemini-2.5-pro"},{"name":"gemini-2.5-flash"}]}`, want: []string{"gemini-2.5-flash", "gemini-2.5-pro"}, }, { name: "top level array", body: `[{"id":"z-model"},{"name":"models/a-model"}]`, want: []string{"a-model", "z-model"}, }, { name: "standard id wins over provider-specific model field", body: `{"data":[{"id":"canonical-id","model":"display-model"}]}`, want: []string{"canonical-id"}, }, { name: "codex manifest uses slug", body: `{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`, want: []string{"gpt-5.5-codex", "gpt-5.6-sol"}, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := extractUpstreamModelIDs([]byte(tt.body)) require.NoError(t, err) require.Equal(t, tt.want, got) }) } } func TestBuildUpstreamModelsRequestSupportsOpenAIOAuth(t *testing.T) { svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()} account := &Account{ ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ "access_token": "openai-oauth-token", "chatgpt_account_id": "chatgpt-account", }, } req, err := svc.buildUpstreamModelsRequest(context.Background(), account) require.NoError(t, err) require.Equal(t, chatgptCodexModelsURL, req.URL.Scheme+"://"+req.URL.Host+req.URL.Path) require.NotEmpty(t, req.URL.Query().Get("client_version")) require.Equal(t, "Bearer openai-oauth-token", req.Header.Get("Authorization")) require.Equal(t, "chatgpt-account", req.Header.Get("chatgpt-account-id")) require.NotEmpty(t, req.Header.Get("Originator")) require.NotEmpty(t, req.Header.Get("User-Agent")) require.NotEmpty(t, req.Header.Get("Version")) } func TestFetchUpstreamSupportedModelsParsesOpenAIOAuthManifest(t *testing.T) { upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`)), }} svc := &AccountTestService{ httpUpstream: upstream, cfg: upstreamModelSyncTestConfig(), } models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ "access_token": "openai-oauth-token", }, }) require.NoError(t, err) require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.6-sol"}, models) require.Equal(t, "Bearer openai-oauth-token", upstream.lastReq.Header.Get("Authorization")) } func TestExtractGrokUpstreamModelIDs(t *testing.T) { t.Parallel() models, err := extractGrokUpstreamModelIDs([]byte(`{"data":[{"id":"display-id","model":"grok-4.5"},{"modelId":"grok-build-0.1"},{"model_id":"grok-composer-2.5-fast"},{"name":"Grok Meta Display Name","_meta":{"model":"grok-meta"}},{"name":"grok-name"},{"id":"grok-safe","_meta":"not-an-object"}]}`)) require.NoError(t, err) require.Equal(t, []string{"grok-4.5", "grok-build-0.1", "grok-composer-2.5-fast", "grok-meta", "grok-name", "grok-safe"}, models) } func TestBuildUpstreamModelsRequestsForAPIKeyAccounts(t *testing.T) { t.Parallel() svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()} ctx := context.Background() anthropicReq, err := svc.buildAnthropicUpstreamModelsRequest(ctx, &Account{ Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "anthropic-key", "base_url": "https://anthropic.example.com/v1", }, }) require.NoError(t, err) require.Equal(t, "https://anthropic.example.com/v1/models", anthropicReq.URL.String()) require.Equal(t, "anthropic-key", anthropicReq.Header.Get("x-api-key")) require.Equal(t, "2023-06-01", anthropicReq.Header.Get("anthropic-version")) anthropicBearerReq, err := svc.buildAnthropicUpstreamModelsRequest(ctx, &Account{ Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "ollama-key", "base_url": "https://ollama.com", }, Extra: map[string]any{ "anthropic_apikey_auth_scheme": AnthropicAPIKeyAuthSchemeAuthorizationBearer, }, }) require.NoError(t, err) require.Equal(t, "https://ollama.com/v1/models", anthropicBearerReq.URL.String()) require.Equal(t, "Bearer ollama-key", anthropicBearerReq.Header.Get("Authorization")) require.Empty(t, anthropicBearerReq.Header.Get("x-api-key")) require.Equal(t, "2023-06-01", anthropicBearerReq.Header.Get("anthropic-version")) openAIReq, err := svc.buildOpenAIUpstreamModelsRequest(ctx, &Account{ Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "openai-key", "base_url": "https://openai.example.com", }, }) require.NoError(t, err) require.Equal(t, "https://openai.example.com/v1/models", openAIReq.URL.String()) require.Equal(t, "Bearer openai-key", openAIReq.Header.Get("Authorization")) grokReq, err := svc.buildUpstreamModelsRequest(ctx, &Account{ Platform: PlatformGrok, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "xai-key", "base_url": "https://xai.example.com/v1", }, }) require.NoError(t, err) require.Equal(t, "https://xai.example.com/v1/models", grokReq.URL.String()) require.Equal(t, "Bearer xai-key", grokReq.Header.Get("Authorization")) geminiReq, err := svc.buildGeminiUpstreamModelsRequest(ctx, &Account{ Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "gemini-key", "base_url": "https://generativelanguage.googleapis.com/v1beta", }, }) require.NoError(t, err) require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", geminiReq.URL.String()) require.Equal(t, "gemini-key", geminiReq.Header.Get("x-goog-api-key")) antigravityReq, err := svc.buildAntigravityAPIKeyModelsRequest(ctx, &Account{ Platform: PlatformAntigravity, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "antigravity-key", "base_url": "https://gateway.example.com/antigravity", }, }) require.NoError(t, err) require.Equal(t, "https://gateway.example.com/antigravity/v1/models", antigravityReq.URL.String()) require.Equal(t, "antigravity-key", antigravityReq.Header.Get("x-api-key")) } func TestBuildUpstreamModelsRequestSupportsGrokOAuth(t *testing.T) { t.Parallel() svc := &AccountTestService{ cfg: upstreamModelSyncTestConfig(), grokTokenProvider: NewGrokTokenProvider(nil, nil), } req, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount("")) require.NoError(t, err) require.Equal(t, "https://cli-chat-proxy.grok.com/v1/models", req.URL.String()) require.Equal(t, "Bearer oauth-access-token", req.Header.Get("Authorization")) require.Equal(t, grokCLIVersion, req.Header.Get("X-Grok-Client-Version")) require.Equal(t, "interactive", req.Header.Get("X-Grok-Client-Mode")) require.Equal(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent")) require.Equal(t, "grok-user-id", req.Header.Get("X-UserID")) require.Equal(t, "grok-user@example.com", req.Header.Get("X-Email")) require.NotContains(t, req.Header.Get("Authorization"), "oauth-refresh-token") } func TestBuildUpstreamModelsRequestGrokOAuthRequiresTokenProvider(t *testing.T) { t.Parallel() svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()} _, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount("")) require.Error(t, err) var syncErr *UpstreamModelSyncError require.True(t, errors.As(err, &syncErr)) require.Equal(t, UpstreamModelSyncErrorConfiguration, syncErr.Kind) require.Contains(t, syncErr.SafeMessage(), "token provider") } func TestBuildAntigravityAPIKeyModelsRequestRejectsOfficialCloudCodeBase(t *testing.T) { t.Parallel() svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()} _, err := svc.buildAntigravityAPIKeyModelsRequest(context.Background(), &Account{ Platform: PlatformAntigravity, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "antigravity-key", "base_url": "https://cloudcode-pa.googleapis.com", }, }) require.Error(t, err) var syncErr *UpstreamModelSyncError require.True(t, errors.As(err, &syncErr)) require.Equal(t, UpstreamModelSyncErrorUnsupported, syncErr.Kind) require.Contains(t, syncErr.SafeMessage(), "compatible gateway") } func TestBuildAnthropicUpstreamModelsRequestRejectsBedrock(t *testing.T) { t.Parallel() svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()} _, err := svc.buildAnthropicUpstreamModelsRequest(context.Background(), &Account{ Platform: PlatformAnthropic, Type: AccountTypeBedrock, }) require.Error(t, err) var syncErr *UpstreamModelSyncError require.True(t, errors.As(err, &syncErr)) require.Equal(t, UpstreamModelSyncErrorUnsupported, syncErr.Kind) } func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) { t.Parallel() upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"},{"id":"gpt-5"},{"name":"o3"}]}`)), }} svc := &AccountTestService{ httpUpstream: upstream, cfg: upstreamModelSyncTestConfig(), } models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ ID: 7, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "openai-key", "base_url": "https://openai.example.com/v1", }, }) require.NoError(t, err) require.Equal(t, []string{"gpt-5", "o3"}, models) require.Equal(t, "https://openai.example.com/v1/models", upstream.lastReq.URL.String()) require.Equal(t, "Bearer openai-key", upstream.lastReq.Header.Get("Authorization")) } // Scenario: ID-only 模型列表从 Models.dev 补齐能力。 func TestSyncUpstreamModelCatalogEnrichesOpenCodeIDOnlyListAndPersistsSnapshot(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"x-preview-f-free","object":"model"}]}`)), }, { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{ "opencode": { "id": "opencode", "name": "OpenCode Zen", "api": "https://opencode.ai/zen/v1", "models": { "x-preview-f-free": { "id": "x-preview-f-free", "name": "Ox Alpha Free (Unlimited)", "description": "Stealth reasoning model for coding, agentic tasks, and tool use", "reasoning": true, "reasoning_options": [{"type":"effort","values":["low","high","max"]}], "modalities": {"input":["text","image","video"],"output":["text"]}, "limit": {"context":1000000,"output":131072} } } } }`)), }, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{ accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig(), } account := &Account{ ID: 91, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "opencode-key", "base_url": "https://opencode.ai/zen/v1", "header_override_enabled": true, "header_overrides": map[string]any{ "X-Custom-Account-Header": "account-secret", }, }, } catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) require.NoError(t, err) require.Equal(t, []string{"x-preview-f-free"}, catalog.Models) require.Len(t, upstream.requests, 2) require.Equal(t, "https://opencode.ai/zen/v1/models", upstream.requests[0].URL.String()) require.Equal(t, []string{"account-secret"}, headerValuesEqualFold(upstream.requests[0].Header, "X-Custom-Account-Header")) require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String()) require.Empty(t, upstream.requests[1].Header.Get("Authorization")) require.Empty(t, upstream.requests[1].Header.Get("x-api-key")) require.Empty(t, headerValuesEqualFold(upstream.requests[1].Header, "X-Custom-Account-Header")) metadata := catalog.Metadata["x-preview-f-free"] require.Equal(t, "Ox Alpha Free (Unlimited)", metadata.DisplayName) require.NotNil(t, metadata.Reasoning) require.True(t, *metadata.Reasoning) require.Equal(t, []string{"low", "high", "max"}, metadata.SupportedReasoningLevels) require.Equal(t, []string{"text", "image"}, metadata.InputModalities) require.Equal(t, int64(1_000_000), metadata.ContextWindow) require.Equal(t, int64(131_072), metadata.MaxOutputTokens) require.Equal(t, int64(91), repo.accountID) rawSnapshot, ok := repo.updates[UpstreamModelMetadataExtraKey] require.True(t, ok) encoded, err := json.Marshal(rawSnapshot) require.NoError(t, err) var snapshot UpstreamModelMetadataSnapshot require.NoError(t, json.Unmarshal(encoded, &snapshot)) require.Equal(t, "models.dev", snapshot.Source) require.Equal(t, metadata, snapshot.Models["x-preview-f-free"]) } // Scenario: 不提供 /models 的兼容上游使用管理员已配置模型继续同步能力。 func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ { StatusCode: http.StatusNotFound, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)), }, { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{ "configured-provider": { "id": "configured-provider", "name": "Configured Provider", "api": "https://provider.example/v1", "models": { "glm-5.3": { "id": "glm-5.3", "name": "GLM-5.3", "reasoning": true, "reasoning_options": [{"type":"effort","values":["low","medium","high"]}], "modalities": {"input":["text"],"output":["text"]}, "limit": {"context":1000000,"output":131072} } } } }`)), }, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} account := &Account{ ID: 97, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "key", "base_url": "https://provider.example/v1", "model_mapping": map[string]any{ "public-glm": "glm-5.3", "duplicate": "glm-5.3", "wildcard": "glm-*", "empty": "", }, }, } account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ "old-live-model": {ID: "old-live-model", ContextWindow: 256000}, }}) catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) require.NoError(t, err) require.Contains(t, account.GetUpstreamModelMetadataSnapshot().Models, "old-live-model", "an unavailable model-list endpoint is not evidence of removal") require.Equal(t, []string{"glm-5.3"}, catalog.Models) require.Empty(t, catalog.Warnings) require.Len(t, upstream.requests, 2) require.Equal(t, "https://provider.example/v1/models", upstream.requests[0].URL.String()) require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String()) metadata := catalog.Metadata["glm-5.3"] require.Equal(t, []string{"low", "medium", "high"}, metadata.SupportedReasoningLevels) require.Equal(t, []string{"text"}, metadata.InputModalities) require.Equal(t, int64(1_000_000), metadata.ContextWindow) require.NotNil(t, repo.updates) } func TestSyncUpstreamModelCatalogDoesNotUseConfiguredModelsForRealUpstreamFailures(t *testing.T) { tests := []struct { name string statusCode int }{ {name: "unauthorized", statusCode: http.StatusUnauthorized}, {name: "rate limited", statusCode: http.StatusTooManyRequests}, {name: "server error", statusCode: http.StatusBadGateway}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: tt.statusCode, Body: io.NopCloser(strings.NewReader(`{"error":"failed"}`)), }} svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 98, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "key", "base_url": "https://provider.example/v1", "model_mapping": map[string]any{"public-glm": "glm-5.3"}, }, }) require.Error(t, err) require.Len(t, upstream.requests, 1) require.Equal(t, tt.statusCode, upstreamModelSyncStatusCode(err)) }) } } func TestSyncUpstreamModelCatalogRequiresConfiguredModelsForUnsupportedListEndpoint(t *testing.T) { upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusMethodNotAllowed, Body: io.NopCloser(strings.NewReader(`{"error":"method not allowed"}`)), }} svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 99, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, }) require.Error(t, err) require.Equal(t, http.StatusMethodNotAllowed, upstreamModelSyncStatusCode(err)) require.Len(t, upstream.requests, 1) } // Scenario: 完整上游模型清单优先保存能力。 func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) { upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"models":[{ "slug":"custom-thinking-model", "display_name":"Upstream Display", "description":"Upstream description", "default_reasoning_level":"high", "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"},{"effort":"ultra"}], "input_modalities":["text","image"], "context_window":256000 }]}`)), }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 92, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, }) require.NoError(t, err) require.Len(t, upstream.requests, 1, "complete upstream metadata must not be replaced by a registry fetch") metadata := catalog.Metadata["custom-thinking-model"] require.Equal(t, "Upstream Display", metadata.DisplayName) require.Equal(t, "high", metadata.DefaultReasoningLevel) require.Equal(t, []string{"low", "high", "ultra"}, metadata.SupportedReasoningLevels) require.Equal(t, []string{"text", "image"}, metadata.InputModalities) require.Equal(t, int64(256_000), metadata.ContextWindow) } // Scenario: 上游 /models 增删型号后,正式同步用最新清单替换能力快照。 func TestSyncUpstreamModelCatalogReplacesSnapshotWhenUpstreamModelsChange(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"data":[ {"id":"old-model","reasoning":false,"input_modalities":["text"],"context_window":128000}, {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000} ]}`)), }, { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"data":[ {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000}, {"id":"new-model","reasoning":false,"input_modalities":["text"],"context_window":256000} ]}`)), }, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} account := &Account{ ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "key", "base_url": "https://provider.example/v1", }, } first, err := svc.SyncUpstreamModelCatalog(context.Background(), account) require.NoError(t, err) require.Equal(t, []string{"kept-model", "old-model"}, first.Models) second, err := svc.SyncUpstreamModelCatalog(context.Background(), account) require.NoError(t, err) require.Equal(t, []string{"kept-model", "new-model"}, second.Models) require.NotContains(t, second.Metadata, "old-model") require.Contains(t, second.Metadata, "new-model") encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) require.NoError(t, err) var snapshot UpstreamModelMetadataSnapshot require.NoError(t, json.Unmarshal(encoded, &snapshot)) require.NotContains(t, snapshot.Models, "old-model") require.Contains(t, snapshot.Models, "new-model") } // Scenario: 上游明确声明无推理能力时保存 false。 func TestSyncUpstreamModelCatalogPersistsExplicitNonReasoningCapability(t *testing.T) { upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"models":[{ "id":"company-coding-model", "display_name":"Company Coding Model", "reasoning":false, "input_modalities":["text"], "context_window":64000 }]}`)), }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 94, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, }) require.NoError(t, err) require.Len(t, upstream.requests, 1) metadata := catalog.Metadata["company-coding-model"] require.NotNil(t, metadata.Reasoning) require.False(t, *metadata.Reasoning) require.Empty(t, metadata.SupportedReasoningLevels) require.Equal(t, []string{"text"}, metadata.InputModalities) require.Equal(t, int64(64_000), metadata.ContextWindow) require.NotNil(t, repo.updates) } func TestSyncUpstreamModelCatalogClassifiesSnapshotPersistenceFailureAsInternal(t *testing.T) { upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"models":[{ "id":"company-coding-model", "reasoning":false, "input_modalities":["text"], "context_window":64000 }]}`)), }} repo := &upstreamModelMetadataRepoStub{err: errors.New("database unavailable")} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 95, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, }) require.Error(t, err) var syncErr *UpstreamModelSyncError require.ErrorAs(t, err, &syncErr) require.Equal(t, UpstreamModelSyncErrorInternal, syncErr.Kind) } // Scenario: 元数据源失败时保留已有快照。 func TestSyncUpstreamModelCatalogDoesNotOverwriteSnapshotWhenRegistryFails(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`))}, {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))}, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 93, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "key", "base_url": "https://opencode.ai/zen/v1"}, Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{ "source": "models.dev", "models": map[string]any{"x-preview-f-free": map[string]any{"reasoning": true}}, }}, }) require.NoError(t, err) require.Equal(t, []string{"x-preview-f-free"}, catalog.Models) require.Empty(t, catalog.Metadata) require.Equal(t, []UpstreamModelSyncWarning{{ Code: UpstreamModelMetadataIncompleteCode, Message: "Model IDs were synced, but capability metadata is incomplete.", }}, catalog.Warnings) require.Nil(t, repo.updates, "a failed metadata enrichment must not erase a previously saved snapshot") } func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"models":[{ "id":"partially-described-model", "display_name":"Partial Model" }]}`))}, {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))}, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{ "source": "upstream", "models": map[string]any{"partially-described-model": map[string]any{ "reasoning": true, "supported_reasoning_levels": []any{"low", "high"}, }}, }}, }) require.NoError(t, err) require.Equal(t, []string{"partially-described-model"}, catalog.Models) require.Equal(t, "Partial Model", catalog.Metadata["partially-described-model"].DisplayName) require.Equal(t, UpstreamModelMetadataIncompleteCode, catalog.Warnings[0].Code) require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot") } // Scenario: 图片专用模型缺少 context 时,不阻止 agent 模型能力落库,也不误报整批失败。 func TestSyncUpstreamModelCatalogIgnoresDedicatedMediaModelsForCompleteness(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[ {"id":"gpt-6-astra","object":"model"}, {"id":"gpt-image-2","object":"model"} ]}`)), }, { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{ "openai": { "id": "openai", "models": { "gpt-6-astra": { "id": "gpt-6-astra", "name": "GPT-6 Astra", "reasoning": true, "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], "modalities": {"input":["text","image"],"output":["text"]}, "limit": {"context":1050000,"output":128000} }, "gpt-image-2": { "id": "gpt-image-2", "name": "gpt-image-2", "reasoning": false, "modalities": {"input":["text","image"],"output":["image"]}, "limit": {"context":0,"output":0} } } } }`)), }, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 113, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "sk-test", "base_url": "https://api.openai.com/v1", "model_mapping": map[string]any{ "gpt-6-astra": "gpt-6-astra", "gpt-image-2": "gpt-image-2", }, }, }) require.NoError(t, err) require.Empty(t, catalog.Warnings, "media generators must not keep agent capability sync in a failed state") require.NotNil(t, repo.updates) encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) require.NoError(t, err) var snapshot UpstreamModelMetadataSnapshot require.NoError(t, json.Unmarshal(encoded, &snapshot)) require.Contains(t, snapshot.Models, "gpt-6-astra") require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, snapshot.Models["gpt-6-astra"].SupportedReasoningLevels) require.NotContains(t, snapshot.Models, "gpt-image-2") } func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { t.Parallel() upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"}]}`)), }} cfg := upstreamModelSyncTestConfig() cfg.Gateway.ModelsListReadMaxBytes = 8 svc := &AccountTestService{httpUpstream: upstream, cfg: cfg} _, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ ID: 7, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "openai-key", "base_url": "https://openai.example.com/v1", }, }) require.Error(t, err) require.Contains(t, err.Error(), "response exceeds 8 bytes") } func TestMatchModelsDevProviderFallsBackToOpenAIProviderWithoutAPIField(t *testing.T) { t.Parallel() registry := map[string]modelsDevProvider{ "openai": { ID: "openai", Name: "OpenAI", // Official models.dev entry currently omits `api`. Models: map[string]modelsDevModel{ "gpt-6-astra": {ID: "gpt-6-astra", Name: "GPT-6 Astra"}, }, }, "opencode": { ID: "opencode", API: "https://opencode.ai/zen/v1", Models: map[string]modelsDevModel{ "x-preview-f-free": {ID: "x-preview-f-free", Name: "Ox"}, }, }, } for _, baseURL := range []string{ "https://api.openai.com", "https://api.openai.com/v1", "https://chatgpt.com/backend-api/codex", } { provider, ok := matchModelsDevProvider(registry, baseURL) require.True(t, ok, baseURL) require.Equal(t, "openai", provider.ID, baseURL) require.Contains(t, provider.Models, "gpt-6-astra", baseURL) } _, ok := matchModelsDevProvider(registry, "https://compatible.example/v1") require.False(t, ok, "custom hosts must not inherit the official OpenAI provider by name") provider, ok := matchModelsDevProvider(registry, "https://opencode.ai/zen/v1") require.True(t, ok) require.Equal(t, "opencode", provider.ID) } // Scenario: 官方 OpenAI ID-only /models + 无 api 字段的 models.dev openai 条目仍能补齐并落库。 func TestSyncUpstreamModelCatalogEnrichesOfficialOpenAIHostWithoutRegistryAPIField(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[ {"id":"gpt-6-astra","object":"model"}, {"id":"gpt-5.6-sol","object":"model"} ]}`)), }, { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{ "openai": { "id": "openai", "name": "OpenAI", "models": { "gpt-6-astra": { "id": "gpt-6-astra", "name": "GPT-6 Astra", "description": "OpenAI GPT-6 Astra", "reasoning": true, "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], "modalities": {"input":["text","image","pdf"],"output":["text"]}, "limit": {"context":1050000,"output":128000} }, "gpt-5.6-sol": { "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "reasoning": true, "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], "modalities": {"input":["text","image"],"output":["text"]}, "limit": {"context":1050000,"output":128000} } } } }`)), }, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 110, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "sk-test", "base_url": "https://api.openai.com/v1", }, }) require.NoError(t, err) require.Empty(t, catalog.Warnings) require.Equal(t, []string{"gpt-5.6-sol", "gpt-6-astra"}, catalog.Models) astra := catalog.Metadata["gpt-6-astra"] require.Equal(t, "GPT-6 Astra", astra.DisplayName) require.NotNil(t, astra.Reasoning) require.True(t, *astra.Reasoning) require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels) require.Equal(t, []string{"text", "image"}, astra.InputModalities) require.Equal(t, int64(1_050_000), astra.ContextWindow) require.NotNil(t, repo.updates) encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) require.NoError(t, err) var snapshot UpstreamModelMetadataSnapshot require.NoError(t, json.Unmarshal(encoded, &snapshot)) require.Equal(t, "models.dev", snapshot.Source) require.Equal(t, astra, snapshot.Models["gpt-6-astra"]) } // Scenario: 同一批同步里部分模型能力完整时仍落库完整条目,并对不完整条目告警。 func TestSyncUpstreamModelCatalogPersistsCompleteModelsWhenSomeRemainIncomplete(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"models":[ { "id":"complete-model", "display_name":"Complete", "reasoning":true, "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}], "input_modalities":["text","image"], "context_window":128000 }, { "id":"incomplete-model", "display_name":"Incomplete Only" } ]}`)), }, { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{ "provider": { "id": "provider", "api": "https://provider.example/v1", "models": {} } }`)), }, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 111, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, }) require.NoError(t, err) require.Equal(t, []string{"complete-model", "incomplete-model"}, catalog.Models) require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code) require.NotNil(t, repo.updates) encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) require.NoError(t, err) var snapshot UpstreamModelMetadataSnapshot require.NoError(t, json.Unmarshal(encoded, &snapshot)) require.Contains(t, snapshot.Models, "complete-model") require.NotContains(t, snapshot.Models, "incomplete-model") require.Equal(t, []string{"low", "high"}, snapshot.Models["complete-model"].SupportedReasoningLevels) } // Scenario: 上游清单未包含管理员 mapping 目标时,仍按 mapping 补齐并写入快照。 func TestSyncUpstreamModelCatalogEnrichesConfiguredMappingModelsMissingFromUpstreamList(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol","object":"model"}]}`)), }, { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{ "openai": { "id": "openai", "models": { "gpt-5.6-sol": { "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "reasoning": true, "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], "modalities": {"input":["text","image"],"output":["text"]}, "limit": {"context":1050000,"output":128000} }, "gpt-6-astra": { "id": "gpt-6-astra", "name": "GPT-6 Astra", "reasoning": true, "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], "modalities": {"input":["text","image"],"output":["text"]}, "limit": {"context":1050000,"output":128000} } } } }`)), }, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "sk-test", "base_url": "https://api.openai.com/v1", "model_mapping": map[string]any{ "gpt-6-astra": "gpt-6-astra", }, }, }) require.NoError(t, err) require.Equal(t, []string{"gpt-5.6-sol"}, catalog.Models, "UI model list stays upstream-only") require.Empty(t, catalog.Warnings) require.Contains(t, catalog.Metadata, "gpt-6-astra") require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, catalog.Metadata["gpt-6-astra"].SupportedReasoningLevels) encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) require.NoError(t, err) var snapshot UpstreamModelMetadataSnapshot require.NoError(t, json.Unmarshal(encoded, &snapshot)) require.Contains(t, snapshot.Models, "gpt-5.6-sol") require.Contains(t, snapshot.Models, "gpt-6-astra") } func TestSyncUpstreamModelCatalogAstraPartialRefreshPreservesKnownCapabilities(t *testing.T) { upstream := &httpUpstreamRecorder{responses: []*http.Response{ {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[ {"id":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null}, {"id":"still-listed"},{"id":"gpt-image-2"} ]}`))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"openai":{"id":"openai","models":{ "gpt-6-astra":{"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"modalities":{"input":["text","image"]},"limit":{"context":1050000,"output":128000}}, "mapped-only":{"reasoning":false,"modalities":{"input":["text"]},"limit":{"context":64000}}, "gpt-image-2":{"reasoning":false,"modalities":{"input":["text","image"]},"limit":{"context":0}} }}}`))}, }} repo := &upstreamModelMetadataRepoStub{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} account := &Account{ID: 114, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test", "base_url": "https://api.openai.com/v1", "model_mapping": map[string]any{"public-model": "mapped-only"}}, } old := UpstreamModelMetadata{ID: "still-listed", ContextWindow: 256000} account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ "still-listed": old, "removed": {ID: "removed", ContextWindow: 128000}, "gpt-6-astra": {ID: "gpt-6-astra", CodexToolCapabilities: map[string]json.RawMessage{ "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), "comp_hash": json.RawMessage(`"3000"`), }}, }}) catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) require.NoError(t, err) require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code) require.NotContains(t, catalog.Models, "mapped-only", "capability enrichment must not change discovery") snapshot := account.GetUpstreamModelMetadataSnapshot() require.Equal(t, old, snapshot.Models["still-listed"]) require.NotContains(t, snapshot.Models, "removed") require.NotContains(t, snapshot.Models, "gpt-image-2") require.Contains(t, snapshot.Models, "mapped-only") astra := snapshot.Models["gpt-6-astra"] require.Equal(t, int64(1050000), astra.ContextWindow) require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels) require.JSONEq(t, "false", string(astra.CodexToolCapabilities["supports_search_tool"])) require.JSONEq(t, "null", string(astra.CodexToolCapabilities["apply_patch_tool_type"])) require.JSONEq(t, `"3000"`, string(astra.CodexToolCapabilities["comp_hash"])) require.NotNil(t, repo.updates) } func TestMatchModelsDevProviderOfficialHostsWithoutAPI(t *testing.T) { registry := map[string]modelsDevProvider{ "openai": {ID: "openai", Models: map[string]modelsDevModel{"gpt-6-astra": {ID: "gpt-6-astra"}}}, "relay": {ID: "relay", API: "https://relay.example/v1"}, } for _, baseURL := range []string{"https://api.openai.com/v1", "https://chatgpt.com/backend-api/codex"} { provider, ok := matchModelsDevProvider(registry, baseURL) require.True(t, ok) require.Equal(t, "openai", provider.ID) } for _, baseURL := range []string{"https://api.openai.com.evil.example/v1", "https://unknown.example/v1"} { _, ok := matchModelsDevProvider(registry, baseURL) require.False(t, ok) } provider, ok := matchModelsDevProvider(registry, "https://relay.example/v1") require.True(t, ok) require.Equal(t, "relay", provider.ID) metadata := map[string]UpstreamModelMetadata{"gpt-6-astra": { Reasoning: new(bool), InputModalities: []string{"text"}, ContextWindow: 1050000, }} require.False(t, upstreamCatalogNeedsRegistry(capabilitySyncModelIDs([]string{"gpt-6-astra", "gpt-image-2"}), metadata)) }