From 983a3db3afc0a7f08b51e4e3091b0c75801b6a6f Mon Sep 17 00:00:00 2001 From: Long Li Date: Sat, 5 Sep 2026 14:45:06 +0900 Subject: [PATCH 1/2] fix(openai): persist GPT-6 Astra Codex capabilities from upstream sync Official OpenAI hosts and models.dev entries without an api field left Astra falling back to reasoning effort none and empty Fast tiers. Persist complete per-model metadata, advertise Astra reasoning/Fast locally, and surface partial sync warnings when image models remain incomplete. Co-authored-by: Cursor --- backend/internal/pkg/openai/constants.go | 1 + backend/internal/pkg/openai/constants_test.go | 12 + backend/internal/service/billing_service.go | 9 +- .../service/openai_codex_models_service.go | 12 +- .../openai_codex_models_service_test.go | 18 + .../internal/service/openai_model_alias.go | 8 + backend/internal/service/upstream_models.go | 177 +++++++-- .../internal/service/upstream_models_test.go | 351 ++++++++++++++---- .../components/account/CreateAccountModal.vue | 5 +- .../components/account/EditAccountModal.vue | 12 +- .../account/ModelWhitelistSelector.vue | 12 +- .../__tests__/ModelWhitelistSelector.spec.ts | 35 ++ .../__tests__/useModelWhitelist.spec.ts | 1 + frontend/src/composables/useModelWhitelist.ts | 5 +- .../src/i18n/locales/en/admin/accounts.ts | 4 +- .../src/i18n/locales/zh/admin/accounts.ts | 3 +- 16 files changed, 545 insertions(+), 120 deletions(-) diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index 772143ac92..3379863839 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -22,6 +22,7 @@ var DefaultModels = []Model{ {ID: "gpt-5.6", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 (Sol)"}, {ID: "gpt-5.6-terra", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Terra"}, {ID: "gpt-5.6-luna", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Luna"}, + {ID: "gpt-6-astra", Object: "model", Created: 1783468800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Astra"}, {ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"}, {ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"}, {ID: "gpt-5.4-mini", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4 Mini"}, diff --git a/backend/internal/pkg/openai/constants_test.go b/backend/internal/pkg/openai/constants_test.go index c8f69efb66..4bfa7dab1c 100644 --- a/backend/internal/pkg/openai/constants_test.go +++ b/backend/internal/pkg/openai/constants_test.go @@ -10,6 +10,18 @@ func TestDefaultModelsIncludeBareGPT56Alias(t *testing.T) { require.Contains(t, DefaultModelIDs(), "gpt-5.6") } +func TestDefaultModelsIncludeGPT6Astra(t *testing.T) { + require.Contains(t, DefaultModelIDs(), "gpt-6-astra") + var displayName string + for _, model := range DefaultModels { + if model.ID == "gpt-6-astra" { + displayName = model.DisplayName + break + } + } + require.Equal(t, "GPT-6 Astra", displayName) +} + func TestDefaultModelsPreferConcreteGPT56SolForAccountTests(t *testing.T) { require.NotEmpty(t, DefaultModels) require.Equal(t, "gpt-5.6-sol", DefaultModels[0].ID) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 1f3a346f64..838bd802f5 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1648,16 +1648,19 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing return &cloned } -// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT-5.x 模型 Fast/priority -// 的标准价倍率:gpt-5.6 系列与 gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast +// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT 模型 Fast/priority +// 的标准价倍率:gpt-5.6 / gpt-6-astra / gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast // 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。 func openAIModelFastPricingRatio(normalized string) float64 { switch normalized { - case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna": + case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra": return 2.0 case "gpt-5.5": return 2.5 default: + if isOpenAIGPT6AstraModel(normalized) { + return 2.0 + } return 0 } } diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 6c97eb9b84..5a5fc93dd9 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -487,7 +487,7 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID) descriptor.DefaultReasoningSummary = "none" descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens} - if isOpenAIGPT56Model(modelID) { + if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) { descriptor.MaxContextWindow = configuredCodexGPT56MaxContext } } @@ -508,7 +508,8 @@ func configuredCodexSupportsPriorityServiceTier(modelID string) bool { return true } } - return false + // GPT-6 Astra advertises Fast via service_tier=priority in public model metadata. + return isOpenAIGPT6AstraModel(modelID) } func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel { @@ -567,7 +568,7 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin {Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"}, } normalized := getNormalizedCodexModel(modelID) - if isOpenAIGPT56Model(modelID) { + if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) { levels = append(levels, configuredCodexReasoningLevel{ Effort: "max", Description: "Maximum reasoning depth for complex tasks", @@ -592,12 +593,13 @@ func isOpenAICodexGPTModel(modelID string) bool { func isOpenAICodexReasoningGPTModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return strings.HasPrefix(normalized, "gpt-5") + return isOpenAIGPT6AstraModel(normalized) || strings.HasPrefix(normalized, "gpt-5") } func isOpenAICodexImageInputModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return strings.HasPrefix(normalized, "gpt-5") || + return isOpenAIGPT6AstraModel(normalized) || + strings.HasPrefix(normalized, "gpt-5") || strings.HasPrefix(normalized, "gpt-4o") || strings.HasPrefix(normalized, "gpt-4.1") || strings.HasPrefix(normalized, "gpt-4.5") || diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 49b5f4e462..67e4d409ca 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -349,6 +349,24 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels)) require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel) + gpt6Astra := newConfiguredCodexModelDescriptor("gpt-6-astra") + require.Equal(t, "GPT-6 Astra", gpt6Astra.DisplayName) + require.NotNil(t, gpt6Astra.DefaultReasoningLevel) + require.Equal(t, "medium", *gpt6Astra.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels)) + require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "ultra"}) + require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) + require.True(t, configuredCodexSupportsPriorityServiceTier("gpt-6-astra")) + require.Equal(t, []configuredCodexServiceTier{{ + ID: "priority", + Name: "Fast", + Description: "Priority processing for lower latency.", + }}, gpt6Astra.ServiceTiers) + require.True(t, isOpenAICodexImageInputModel("gpt-6-astra")) + require.True(t, isOpenAICodexReasoningGPTModel("openai/gpt-6-astra")) + require.True(t, isOpenAIGPT6AstraModel("gpt-6-astra-2026-09-01")) + require.False(t, isOpenAIGPT6AstraModel("gpt-6-other")) + gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5") require.Equal(t, "GPT-5.5", gpt55.DisplayName) require.NotNil(t, gpt55.DefaultReasoningLevel) diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go index 4ddf8110c3..10fd26e777 100644 --- a/backend/internal/service/openai_model_alias.go +++ b/backend/internal/service/openai_model_alias.go @@ -124,6 +124,14 @@ func isOpenAIGPT56Model(model string) bool { return false } +// isOpenAIGPT6AstraModel reports GPT-6 Astra and dated/provider-prefixed variants. +// Bare "gpt-6" and other future gpt-6-* models are intentionally excluded until +// their capabilities are confirmed (see #6622). +func isOpenAIGPT6AstraModel(model string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(model) + return normalized == "gpt-6-astra" || strings.HasPrefix(normalized, "gpt-6-astra-") +} + func appendUsageBillingModelCandidate(candidates []string, seen map[string]struct{}, model string) []string { trimmed := strings.TrimSpace(model) if trimmed == "" { diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index d3d88c4269..5bab9fa068 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -24,6 +24,7 @@ const ( modelsDevRegistryTTL = 6 * time.Hour UpstreamModelMetadataExtraKey = "upstream_model_metadata" UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete" + UpstreamModelMetadataPartialCode = "upstream_model_metadata_partial" ) type UpstreamModelMetadata struct { @@ -197,7 +198,13 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a // SyncUpstreamModelCatalog fetches the account's live model list, enriches // missing capability fields from the provider registry used by the upstream, -// and persists a normalized account snapshot when metadata is available. +// and persists a normalized account snapshot when complete metadata is available. +// +// Persistence is per-model: models with complete capability fields are saved even +// when other IDs in the same sync remain incomplete. An incomplete warning is +// still returned so admins can tell ID sync succeeded without a full capability +// snapshot. When no model is complete, the existing account snapshot is left +// untouched. func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) { models, body, err := s.fetchUpstreamModelList(ctx, account) if err != nil { @@ -222,10 +229,18 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou } } + // Capability enrichment also covers concrete model_mapping targets. Admins may + // whitelist models that the live /models list omitted; those still need registry + // metadata so Codex catalogs can advertise reasoning and modalities. + enrichIDs := dedupeAndSortModelIDs(append(append([]string{}, models...), configuredUpstreamModelsForCapabilitySync(account)...)) + // Dedicated image/video generators are not Codex agent catalog entries and often + // omit context windows in public registries. Keep them out of completeness checks + // so they do not mask successful agent-model capability sync. + capabilityIDs := capabilitySyncModelIDs(enrichIDs) + source := "upstream" - metadataIncomplete := upstreamCatalogNeedsRegistry(models, catalog.Metadata) - if metadataIncomplete { - if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, models); registryErr == nil { + if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { + if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, enrichIDs); registryErr == nil { for modelID, fallback := range registryMetadata { current := catalog.Metadata[modelID] merged, changed := mergeUpstreamModelMetadata(current, fallback) @@ -243,25 +258,34 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou } } - if upstreamCatalogNeedsRegistry(models, catalog.Metadata) { - catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ - Code: UpstreamModelMetadataIncompleteCode, - Message: "Model IDs were synced, but capability metadata is incomplete.", - }) - return catalog, nil + completeMetadata := completeUpstreamModelMetadataSubset(capabilityIDs, catalog.Metadata) + persistedCapabilities := false + if len(completeMetadata) > 0 && account != nil && account.ID > 0 && s.accountRepo != nil { + snapshot := UpstreamModelMetadataSnapshot{ + Source: source, + SyncedAt: time.Now().UTC().Format(time.RFC3339), + Models: completeMetadata, + } + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { + return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) + } + account.SetUpstreamModelMetadataSnapshot(snapshot) + persistedCapabilities = true } - if len(catalog.Metadata) == 0 || account == nil || account.ID <= 0 || s.accountRepo == nil { - return catalog, nil + + if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { + if persistedCapabilities { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataPartialCode, + Message: "Some model capabilities were saved; remaining models are still incomplete.", + }) + } else { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataIncompleteCode, + Message: "Model IDs were synced, but capability metadata is incomplete.", + }) + } } - snapshot := UpstreamModelMetadataSnapshot{ - Source: source, - SyncedAt: time.Now().UTC().Format(time.RFC3339), - Models: catalog.Metadata, - } - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { - return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) - } - account.SetUpstreamModelMetadataSnapshot(snapshot) return catalog, nil } @@ -293,6 +317,18 @@ func configuredUpstreamModelsForCapabilitySync(account *Account) []string { return dedupeAndSortModelIDs(models) } +func capabilitySyncModelIDs(modelIDs []string) []string { + filtered := make([]string, 0, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" || isCodexDedicatedMediaModel(modelID) { + continue + } + filtered = append(filtered, modelID) + } + return filtered +} + func upstreamModelSyncAccountID(account *Account) int64 { if account == nil { return 0 @@ -311,13 +347,7 @@ func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamM for _, modelID := range models { modelID = strings.TrimSpace(modelID) model, ok := metadata[modelID] - if !ok || !upstreamModelMetadataIsUseful(model) { - return true - } - if model.Reasoning == nil || len(model.InputModalities) == 0 || model.ContextWindow <= 0 { - return true - } - if *model.Reasoning && len(model.SupportedReasoningLevels) == 0 { + if !ok || !upstreamModelMetadataIsComplete(model) { return true } } @@ -334,6 +364,52 @@ func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool { metadata.MaxOutputTokens > 0 } +// upstreamModelMetadataIsComplete reports whether a snapshot entry is safe to +// persist and later prefer over local Codex name-based fallbacks. +func upstreamModelMetadataIsComplete(metadata UpstreamModelMetadata) bool { + if metadata.Reasoning == nil { + return false + } + if len(normalizeCodexInputModalities(metadata.InputModalities)) == 0 { + return false + } + if metadata.ContextWindow <= 0 { + return false + } + if *metadata.Reasoning && len(normalizeReasoningLevels(metadata.SupportedReasoningLevels)) == 0 { + return false + } + return true +} + +func completeUpstreamModelMetadataSubset( + modelIDs []string, + metadata map[string]UpstreamModelMetadata, +) map[string]UpstreamModelMetadata { + if len(metadata) == 0 { + return nil + } + complete := make(map[string]UpstreamModelMetadata) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + entry, ok := metadata[modelID] + if !ok || !upstreamModelMetadataIsComplete(entry) { + continue + } + if strings.TrimSpace(entry.ID) == "" { + entry.ID = modelID + } + complete[modelID] = entry + } + if len(complete) == 0 { + return nil + } + return complete +} + func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (UpstreamModelMetadata, bool) { merged := primary changed := false @@ -529,6 +605,13 @@ func upstreamModelRegistryBaseURL(account *Account) string { } func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { + if provider, ok := matchModelsDevProviderByAPIURL(registry, accountBaseURL); ok { + return provider, true + } + return matchModelsDevProviderByKnownHost(registry, accountBaseURL) +} + +func matchModelsDevProviderByAPIURL(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL) if accountBaseURL == "" { return modelsDevProvider{}, false @@ -553,6 +636,44 @@ func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseUR return best, bestScore >= 0 } +// matchModelsDevProviderByKnownHost covers first-party hosts whose models.dev +// entries omit the `api` field (notably the official OpenAI provider). Custom +// compatible gateways must still match by API URL so same-named models are not +// cross-attributed across vendors. +func matchModelsDevProviderByKnownHost(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { + host := modelRegistryHostname(accountBaseURL) + if host == "" { + return modelsDevProvider{}, false + } + providerID := "" + switch host { + case "api.openai.com", "chatgpt.com": + providerID = "openai" + default: + return modelsDevProvider{}, false + } + provider, ok := registry[providerID] + if !ok || len(provider.Models) == 0 { + return modelsDevProvider{}, false + } + if strings.TrimSpace(provider.ID) == "" { + provider.ID = providerID + } + return provider, true +} + +func modelRegistryHostname(raw string) string { + normalized := normalizeModelRegistryBaseURL(raw) + if normalized == "" { + return "" + } + parsed, err := url.Parse(normalized) + if err != nil { + return "" + } + return strings.ToLower(strings.TrimSpace(parsed.Hostname())) +} + func normalizeModelRegistryBaseURL(raw string) string { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index b358406b8b..d11870f7ff 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -795,6 +795,71 @@ func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails( 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() @@ -820,101 +885,231 @@ func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { require.Contains(t, err.Error(), "response exceeds 8 bytes") } -func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) { +func TestMatchModelsDevProviderFallsBackToOpenAIProviderWithoutAPIField(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":"grok-4.5"},{"id":"grok-4.5"},{"id":"grok-imagine"}]}`)), - }} - svc := &AccountTestService{ - httpUpstream: upstream, - cfg: upstreamModelSyncTestConfig(), + 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"}, + }, + }, } - models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ - ID: 9, - Platform: PlatformGrok, - Type: AccountTypeAPIKey, + 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": "xai-key", - "base_url": "https://xai.example.com/v1", + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", }, }) require.NoError(t, err) - require.Equal(t, []string{"grok-4.5", "grok-imagine"}, models) - require.Equal(t, "https://xai.example.com/v1/models", upstream.lastReq.URL.String()) - require.Equal(t, "Bearer xai-key", upstream.lastReq.Header.Get("Authorization")) -} + require.Empty(t, catalog.Warnings) + require.Equal(t, []string{"gpt-5.6-sol", "gpt-6-astra"}, catalog.Models) -func TestFetchUpstreamSupportedModelsParsesGrokOAuthResponse(t *testing.T) { - t.Parallel() + 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) - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"data":[{"model":"grok-4.5"},{"model":"grok-4.5"},{"modelId":"grok-build-0.1"}]}`)), - }} - svc := &AccountTestService{ - httpUpstream: upstream, - cfg: upstreamModelSyncTestConfig(), - grokTokenProvider: NewGrokTokenProvider(nil, nil), - } - - models, err := svc.FetchUpstreamSupportedModels(context.Background(), grokOAuthModelSyncTestAccount("")) + require.NotNil(t, repo.updates) + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) require.NoError(t, err) - require.Equal(t, []string{"grok-4.5", "grok-build-0.1"}, models) - require.Equal(t, "https://cli-chat-proxy.grok.com/v1/models", upstream.lastReq.URL.String()) - require.Equal(t, "Bearer oauth-access-token", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode")) - require.Equal(t, "grok-user-id", upstream.lastReq.Header.Get("X-UserID")) - require.Equal(t, "grok-user@example.com", upstream.lastReq.Header.Get("X-Email")) + 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"]) } -func TestBuildUpstreamModelsRequestGrokOAuthDoesNotSendIdentityToCustomBase(t *testing.T) { - t.Parallel() - - svc := &AccountTestService{ - cfg: upstreamModelSyncTestConfig(), - grokTokenProvider: NewGrokTokenProvider(nil, nil), - } - req, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount("https://relay.example/v1")) - require.NoError(t, err) - require.Equal(t, "https://relay.example/v1/models", req.URL.String()) - require.Empty(t, req.Header.Get("X-UserID")) - require.Empty(t, req.Header.Get("X-Email")) -} - -func TestFetchUpstreamSupportedModelsDoesNotExposeUpstreamBody(t *testing.T) { - t.Parallel() - - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusBadGateway, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"error":"SECRET_TOKEN should not be exposed"}`)), +// 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": {} + } + }`)), + }, }} - svc := &AccountTestService{ - httpUpstream: upstream, - cfg: upstreamModelSyncTestConfig(), - } + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} - _, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ - ID: 8, - Platform: PlatformOpenAI, - Type: AccountTypeAPIKey, + 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": "openai-key", - "base_url": "https://openai.example.com/v1", + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + "model_mapping": map[string]any{ + "gpt-6-astra": "gpt-6-astra", + }, }, }) - require.Error(t, err) - require.NotContains(t, err.Error(), "SECRET_TOKEN") + 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) - var syncErr *UpstreamModelSyncError - require.True(t, errors.As(err, &syncErr)) - require.Equal(t, UpstreamModelSyncErrorUpstream, syncErr.Kind) - require.NotContains(t, syncErr.SafeMessage(), "SECRET_TOKEN") - require.Contains(t, syncErr.SafeMessage(), "HTTP 502") + 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") } diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 8702ca7595..23d3fff98b 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -5046,8 +5046,11 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => { if (upstreamModelsPreviewed.value || hasConcreteMappedTarget) { try { const result = await adminAPI.accounts.syncUpstreamModels(account.id) - if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) { + const warnings = result.warnings ?? [] + if (warnings.some(warning => warning.code === 'upstream_model_metadata_incomplete')) { appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete')) + } else if (warnings.some(warning => warning.code === 'upstream_model_metadata_partial')) { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataPartial')) } } catch { appStore.showWarning(t('admin.accounts.syncUpstreamModelsFailed')) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8ef424f4d9..952114df37 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -4180,7 +4180,14 @@ const syncAntigravityUpstreamModels = async () => { } } - if (result.warnings?.some((warning) => warning.code === 'upstream_model_metadata_incomplete')) { + const warnings = result.warnings ?? [] + const hasPartialMetadata = warnings.some( + (warning) => warning.code === 'upstream_model_metadata_partial' + ) + const hasIncompleteMetadata = warnings.some( + (warning) => warning.code === 'upstream_model_metadata_incomplete' + ) + if (hasIncompleteMetadata) { appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete')) return } @@ -4189,6 +4196,9 @@ const syncAntigravityUpstreamModels = async () => { } else { appStore.showInfo(t('admin.accounts.syncUpstreamModelsNoChanges', { count: upstreamModels.length })) } + if (hasPartialMetadata) { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataPartial')) + } } catch (error) { const message = error instanceof Error ? error.message : t('admin.accounts.syncUpstreamModelsFailed') appStore.showError(t('admin.accounts.syncUpstreamModelsError', { message })) diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index a3b1309fbf..11f38c9892 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -327,7 +327,14 @@ const syncUpstreamModels = async () => { } emit('update:modelValue', newModels) - if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) { + const warnings = result.warnings ?? [] + const hasPartialMetadata = warnings.some( + warning => warning.code === 'upstream_model_metadata_partial' + ) + const hasIncompleteMetadata = warnings.some( + warning => warning.code === 'upstream_model_metadata_incomplete' + ) + if (hasIncompleteMetadata) { appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete')) return } @@ -336,6 +343,9 @@ const syncUpstreamModels = async () => { } else { appStore.showInfo(t('admin.accounts.syncUpstreamModelsNoChanges', { count: upstreamModels.length })) } + if (hasPartialMetadata) { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataPartial')) + } } catch (error) { const message = error instanceof Error ? error.message : t('admin.accounts.syncUpstreamModelsFailed') appStore.showError(t('admin.accounts.syncUpstreamModelsError', { message })) diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index 928ab6e721..3ddea14420 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -153,6 +153,41 @@ describe('ModelWhitelistSelector', () => { expect(showSuccess).not.toHaveBeenCalled() }) + it('shows success and a partial warning when some capabilities were saved', async () => { + syncUpstreamModels.mockResolvedValue({ + models: ['gpt-6-astra', 'gpt-image-2'], + warnings: [ + { + code: 'upstream_model_metadata_partial', + message: 'Some model capabilities were saved; remaining models are still incomplete.' + } + ] + }) + const wrapper = mount(ModelWhitelistSelector, { + props: { + modelValue: [], + platform: 'openai', + accountId: 46 + }, + global: { + stubs: { + ModelIcon: true + } + } + }) + + const syncButton = wrapper + .findAll('button') + .find(button => button.text() === 'admin.accounts.syncUpstreamModels') + expect(syncButton).toBeDefined() + await syncButton!.trigger('click') + await flushPromises() + + expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-6-astra', 'gpt-image-2']]]) + expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsSuccess') + expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataPartial') + }) + it('reports a successful preview so account creation can persist metadata', async () => { syncUpstreamModelsPreview.mockResolvedValue({ models: ['x-preview-f-free'], diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index a4198a7afb..238354f816 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -15,6 +15,7 @@ describe('useModelWhitelist', () => { expect(models).toContain('gpt-5.4-2026-03-05') expect(models).toContain('codex-auto-review') expect(models).toContain('gpt-5.6') + expect(models).toContain('gpt-6-astra') }) it('openai 模型列表不再暴露已下线的 ChatGPT 登录 Codex 模型', () => { diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index b17427a8a2..a6ea72d17a 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -7,8 +7,10 @@ const openaiModels = [ // GPT-5.2 系列 'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11', - // GPT-5.6 系列 + // GPT-5.6 系列 'gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', + // GPT-6 系列 + 'gpt-6-astra', // GPT-5.5 系列 'gpt-5.5', // GPT-5.4 系列 @@ -294,6 +296,7 @@ const openaiPresetMappings = [ { label: 'GPT-5.6 Sol', from: 'gpt-5.6-sol', to: 'gpt-5.6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, { label: 'GPT-5.6 Terra', from: 'gpt-5.6-terra', to: 'gpt-5.6-terra', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' }, { label: 'GPT-5.6 Luna', from: 'gpt-5.6-luna', to: 'gpt-5.6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }, + { label: 'GPT-6 Astra', from: 'gpt-6-astra', to: 'gpt-6-astra', color: 'bg-fuchsia-100 text-fuchsia-700 hover:bg-fuchsia-200 dark:bg-fuchsia-900/30 dark:text-fuchsia-400' }, { label: 'GPT-5.5', from: 'gpt-5.5', to: 'gpt-5.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, { label: 'GPT-5.4', from: 'gpt-5.4', to: 'gpt-5.4', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' }, { label: 'Haiku→5.4', from: 'claude-haiku-4-5-20251001', to: 'gpt-5.4', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index a172ca3ddd..c7afd77609 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -759,7 +759,9 @@ export default { syncUpstreamModelsFailed: 'Failed to sync upstream models', syncUpstreamModelsError: 'Failed to sync upstream models: {message}', syncUpstreamModelsMetadataIncomplete: - 'Model IDs were synced, but capability metadata is incomplete and was not updated.', + 'Model IDs were synced, but no capability metadata could be updated.', + syncUpstreamModelsMetadataPartial: + 'Some model capabilities were updated; remaining models are still incomplete.', clearAllModels: 'Clear all models', customModelName: 'Custom model name', enterCustomModelName: 'Enter custom model name', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index b4d2684f70..1d72cd7c01 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -834,7 +834,8 @@ export default { syncUpstreamModelsEmpty: '上游没有返回可同步的模型', syncUpstreamModelsFailed: '同步上游模型失败', syncUpstreamModelsError: '同步上游模型失败:{message}', - syncUpstreamModelsMetadataIncomplete: '模型 ID 已同步,但能力元数据不完整,能力信息未更新。', + syncUpstreamModelsMetadataIncomplete: '模型 ID 已同步,但未能更新任何能力元数据。', + syncUpstreamModelsMetadataPartial: '已更新部分模型的能力元数据;其余模型能力仍不完整。', clearAllModels: '清除所有模型', customModelName: '自定义模型名称', enterCustomModelName: '输入自定义模型名称', From a016bd4ba5dd1920c11d0c853f56c3f2f3c94e11 Mon Sep 17 00:00:00 2001 From: Long Li Date: Sat, 5 Sep 2026 14:58:51 +0900 Subject: [PATCH 2/2] style: gofmt upstream models capability sync tests Co-authored-by: Cursor --- backend/internal/service/upstream_models_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index d11870f7ff..0ed985931e 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -890,7 +890,7 @@ func TestMatchModelsDevProviderFallsBackToOpenAIProviderWithoutAPIField(t *testi registry := map[string]modelsDevProvider{ "openai": { - ID: "openai", + ID: "openai", Name: "OpenAI", // Official models.dev entry currently omits `api`. Models: map[string]modelsDevModel{ @@ -1057,7 +1057,7 @@ func TestSyncUpstreamModelCatalogEnrichesConfiguredMappingModelsMissingFromUpstr { 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"}]}`)), + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol","object":"model"}]}`)), }, { StatusCode: http.StatusOK,