diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 93067da278..6a83405cc0 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2774,13 +2774,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) { return } - models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account) + catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account) if err != nil { var syncErr *service.UpstreamModelSyncError if errors.As(err, &syncErr) { switch syncErr.Kind { case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported: response.BadRequest(c, syncErr.SafeMessage()) + case service.UpstreamModelSyncErrorInternal: + response.InternalError(c, syncErr.SafeMessage()) default: slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind) response.Error(c, http.StatusBadGateway, syncErr.SafeMessage()) @@ -2793,7 +2795,7 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) { return } - response.Success(c, gin.H{"models": models}) + response.Success(c, catalog) } // SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed). @@ -2824,13 +2826,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) { return } - models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount) + catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount) if err != nil { var syncErr *service.UpstreamModelSyncError if errors.As(err, &syncErr) { switch syncErr.Kind { case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported: response.BadRequest(c, syncErr.SafeMessage()) + case service.UpstreamModelSyncErrorInternal: + response.InternalError(c, syncErr.SafeMessage()) default: slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind) response.Error(c, http.StatusBadGateway, syncErr.SafeMessage()) @@ -2843,7 +2847,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) { return } - response.Success(c, gin.H{"models": models}) + response.Success(c, catalog) } // SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go index ef2f65d4e3..2942f0578b 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine { } type syncUpstreamHTTPUpstream struct { - resp *http.Response - err error + resp *http.Response + responses []*http.Response + err error } func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { if u.err != nil { return nil, u.err } + if len(u.responses) > 0 { + resp := u.responses[0] + u.responses = u.responses[1:] + return resp, nil + } return u.resp, nil } @@ -347,6 +353,46 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin require.Contains(t, rec.Body.String(), "No OpenAI API key is available") } +func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) { + svc := &availableModelsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, Status: service.StatusActive, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }, + } + upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "id":"custom-thinking-model", + "reasoning":true, + "default_reasoning_level":"high", + "supported_reasoning_levels":["low","high"], + "input_modalities":["text","image"], + "context_window":256000 + }]}`)), + }} + router := setupSyncUpstreamModelsRouter(svc, upstream) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Data service.UpstreamModelCatalog `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models) + metadata := resp.Data.Metadata["custom-thinking-model"] + require.NotNil(t, metadata.Reasoning) + require.True(t, *metadata.Reasoning) + require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, metadata.InputModalities) +} + func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) { svc := &availableModelsAdminService{ stubAdminService: newStubAdminService(), @@ -377,3 +423,52 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502") require.NotContains(t, rec.Body.String(), "SECRET_TOKEN") } + +// Scenario: 能力补全失败显示部分成功。 +func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) { + svc := &availableModelsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 46, + Name: "opencode-id-only-model-list", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Credentials: map[string]any{ + "api_key": "opencode-key", + "base_url": "https://opencode.ai/zen/v1", + }, + }, + } + upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)), + }, + { + StatusCode: http.StatusBadGateway, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)), + }, + }} + router := setupSyncUpstreamModelsRouter(svc, upstream) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Data struct { + Models []string `json:"models"` + Warnings []struct { + Code string `json:"code"` + } `json:"warnings"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models) + require.Len(t, resp.Data.Warnings, 1) + require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code) +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index a6f592ee23..c7958f8a11 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -146,6 +146,9 @@ type AccountTestService struct { cfg *config.Config settingService *SettingService tlsFPProfileService *TLSFingerprintProfileService + modelMetadataRegistryMu sync.Mutex + modelMetadataRegistry map[string]modelsDevProvider + modelMetadataRegistryAt time.Time agentIdentityTaskMu sync.Mutex agentIdentityWS agentIdentityWSConnectionInvalidator // grokWSDialer is optional; realtime account tests use the default OpenAI-style diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go new file mode 100644 index 0000000000..71651b3271 --- /dev/null +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -0,0 +1,287 @@ +package service + +import "strings" + +func groupCodexModelMetadata( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) (codexModelMetadataOverride, bool) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return codexModelMetadataOverride{}, false + } + upstreamModel := modelID + if platform == PlatformComposite { + var resolved bool + platform, upstreamModel, resolved = resolveCodexCompositeModelTarget( + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if !resolved { + if codexExplicitModelTargetsConflict(accounts, modelID) { + return codexModelMetadataOverride{ + reasoningConflict: true, + inputModalitiesConflict: true, + }, true + } + return codexModelMetadataOverride{}, false + } + } + if !isConcreteRequestPlatform(platform) { + return codexModelMetadataOverride{}, false + } + + explicitClaims := false + if upstreamModel == modelID { + for _, account := range accounts { + if account.Platform == platform && codexExplicitModelMappingClaims(account, modelID) { + explicitClaims = true + break + } + } + } + if explicitClaims && codexExplicitModelTargetsConflict(accounts, modelID) { + return codexModelMetadataOverride{ + reasoningConflict: true, + inputModalitiesConflict: true, + }, true + } + + candidates := make([]UpstreamModelMetadata, 0) + for i := range accounts { + account := &accounts[i] + if account.Platform != platform { + continue + } + var lookupModel string + if explicitClaims { + if !codexExplicitModelMappingClaims(*account, modelID) { + continue + } + lookupModel = account.GetMappedModel(modelID) + } else { + if !account.IsModelSupported(upstreamModel) { + continue + } + lookupModel = account.GetMappedModel(upstreamModel) + } + metadata, ok := account.GetUpstreamModelMetadata(lookupModel) + if !ok { + return codexModelMetadataOverride{}, false + } + candidates = append(candidates, metadata) + } + if len(candidates) == 0 { + return codexModelMetadataOverride{}, false + } + return intersectUpstreamModelMetadata(modelID, candidates), true +} + +func codexExplicitModelTargetsConflict(accounts []Account, modelID string) bool { + targets := make(map[string]struct{}) + for i := range accounts { + account := &accounts[i] + mappedModel, matched := account.ResolveMappedModel(modelID) + mappedModel = strings.TrimSpace(mappedModel) + if !matched || mappedModel == "" { + continue + } + targets[strings.TrimSpace(account.Platform)+"\x00"+mappedModel] = struct{}{} + } + return len(targets) > 1 +} + +func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMetadata) codexModelMetadataOverride { + result := codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ID: strings.TrimSpace(modelID)}} + for _, candidate := range candidates { + if result.DisplayName == "" && strings.TrimSpace(candidate.DisplayName) != "" { + result.DisplayName = strings.TrimSpace(candidate.DisplayName) + } + if result.Description == "" && strings.TrimSpace(candidate.Description) != "" { + result.Description = strings.TrimSpace(candidate.Description) + } + } + + reasoningKnown := true + reasoningValue := false + for i, candidate := range candidates { + if candidate.Reasoning == nil { + reasoningKnown = false + break + } + if i == 0 { + reasoningValue = *candidate.Reasoning + continue + } + if reasoningValue != *candidate.Reasoning { + reasoningKnown = false + result.reasoningConflict = true + break + } + } + if reasoningKnown { + result.Reasoning = &reasoningValue + if reasoningValue { + levels := normalizeReasoningLevels(candidates[0].SupportedReasoningLevels) + for _, candidate := range candidates[1:] { + levels = intersectOrderedStrings(levels, normalizeReasoningLevels(candidate.SupportedReasoningLevels)) + } + result.SupportedReasoningLevels = levels + if len(levels) == 0 { + result.reasoningConflict = true + } else { + sharedDefault := normalizeReasoningLevel(candidates[0].DefaultReasoningLevel) + for _, candidate := range candidates[1:] { + if normalizeReasoningLevel(candidate.DefaultReasoningLevel) != sharedDefault { + sharedDefault = "" + break + } + } + if !stringSliceContains(levels, sharedDefault) { + sharedDefault = levels[0] + } + result.DefaultReasoningLevel = sharedDefault + } + } + } + + modalitiesKnown := true + modalities := normalizeCodexInputModalities(candidates[0].InputModalities) + if len(modalities) == 0 { + modalitiesKnown = false + } + for _, candidate := range candidates[1:] { + candidateModalities := normalizeCodexInputModalities(candidate.InputModalities) + if len(candidateModalities) == 0 { + modalitiesKnown = false + break + } + modalities = intersectOrderedStrings(modalities, candidateModalities) + } + if modalitiesKnown && len(modalities) > 0 { + result.InputModalities = modalities + } else if modalitiesKnown { + result.inputModalitiesConflict = true + } + + contextKnown := true + for i, candidate := range candidates { + if candidate.ContextWindow <= 0 { + contextKnown = false + break + } + if i == 0 || candidate.ContextWindow < result.ContextWindow { + result.ContextWindow = candidate.ContextWindow + } + } + if !contextKnown { + result.ContextWindow = 0 + } + return result +} + +func applyUpstreamModelMetadataToCodexDescriptor( + descriptor *configuredCodexModelDescriptor, + metadata codexModelMetadataOverride, +) { + if descriptor == nil { + return + } + if strings.TrimSpace(metadata.DisplayName) != "" { + descriptor.DisplayName = strings.TrimSpace(metadata.DisplayName) + } + if strings.TrimSpace(metadata.Description) != "" { + descriptor.Description = strings.TrimSpace(metadata.Description) + } + if metadata.reasoningConflict { + descriptor.DefaultReasoningLevel = nil + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{} + } else if metadata.Reasoning != nil && !*metadata.Reasoning { + none := "none" + descriptor.DefaultReasoningLevel = &none + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{{ + Effort: "none", + Description: configuredCodexReasoningLevelDescription("none"), + }} + } else if metadata.Reasoning != nil && *metadata.Reasoning { + levels := normalizeReasoningLevels(metadata.SupportedReasoningLevels) + if len(levels) == 0 { + descriptor.DefaultReasoningLevel = nil + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{} + } else { + defaultLevel := normalizeReasoningLevel(metadata.DefaultReasoningLevel) + if !stringSliceContains(levels, defaultLevel) { + defaultLevel = levels[0] + } + descriptor.DefaultReasoningLevel = &defaultLevel + descriptor.SupportedReasoningLevels = make([]configuredCodexReasoningLevel, 0, len(levels)) + for _, level := range levels { + descriptor.SupportedReasoningLevels = append(descriptor.SupportedReasoningLevels, configuredCodexReasoningLevel{ + Effort: level, + Description: configuredCodexReasoningLevelDescription(level), + }) + } + } + } + if metadata.inputModalitiesConflict { + descriptor.InputModalities = []string{"text"} + } else if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 { + descriptor.InputModalities = modalities + } + if metadata.ContextWindow > 0 { + descriptor.ContextWindow = metadata.ContextWindow + descriptor.MaxContextWindow = metadata.ContextWindow + } +} + +func configuredCodexReasoningLevelDescription(level string) string { + switch level { + case "none": + return "Use the model's default behavior without configurable reasoning" + case "minimal": + return "Minimal reasoning for the fastest responses" + case "low": + return "Fast responses with lighter reasoning" + case "medium": + return "Balanced reasoning for most coding tasks" + case "high": + return "Greater reasoning depth for coding and agent tasks" + case "xhigh": + return "Extra-high reasoning depth for difficult tasks" + case "max": + return "Maximum reasoning depth for complex tasks" + default: + return "Reasoning effort supported by the upstream model" + } +} + +func intersectOrderedStrings(left, right []string) []string { + rightSet := make(map[string]struct{}, len(right)) + for _, value := range right { + rightSet[value] = struct{}{} + } + intersection := make([]string, 0, len(left)) + for _, value := range left { + if _, ok := rightSet[value]; ok { + intersection = append(intersection, value) + } + } + return intersection +} + +func stringSliceContains(values []string, target string) bool { + if target == "" { + return false + } + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go new file mode 100644 index 0000000000..4baa47c10c --- /dev/null +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -0,0 +1,180 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// Scenario: mixed groups prefer capability metadata synced for the routed account. +func TestBuildCodexModelsManifestForGroupUsesSyncedAccountMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 735 + account := Account{ + ID: 25, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://opencode.ai/zen/v1", + "model_mapping": map[string]any{"x-preview-f-free": "x-preview-f-free"}, + }, + Extra: map[string]any{ + UpstreamModelMetadataExtraKey: map[string]any{ + "source": "models.dev", + "models": map[string]any{ + "x-preview-f-free": map[string]any{ + "id": "x-preview-f-free", + "display_name": "Ox Alpha Free (Unlimited)", + "description": "Stealth reasoning model", + "reasoning": true, + "supported_reasoning_levels": []any{"low", "high", "max"}, + "input_modalities": []any{"text", "image"}, + "context_window": float64(1_000_000), + "max_output_tokens": float64(131_072), + }, + }, + }, + }, + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {account}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"x-preview-f-free"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "Ox Alpha Free (Unlimited)", models[0]["display_name"]) + require.Equal(t, "low", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"low", "high", "max"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) +} + +// Scenario: an explicitly non-reasoning model remains directly selectable in Codex. +func TestBuildCodexModelsManifestForGroupUsesNoneForExplicitNonReasoningMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 737 + reasoning := false + account := Account{ + ID: 28, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"company-coding-model": "company-coding-model"}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "company-coding-model": { + ID: "company-coding-model", Reasoning: &reasoning, + InputModalities: []string{"text"}, ContextWindow: 64_000, + }, + }}) + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {account}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"company-coding-model"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "none", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"none"}, effortsFromManifestModel(t, models[0])) +} + +// Scenario: multiple schedulable accounts advertise only their shared capabilities. +func TestBuildCodexModelsManifestForGroupIntersectsSyncedAccountMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 736 + reasoning := true + newAccount := func(id int64, levels, modalities []string, contextWindow int64) Account { + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"shared-model": "shared-model"}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "shared-model": { + ID: "shared-model", Reasoning: &reasoning, + SupportedReasoningLevels: levels, + InputModalities: modalities, + ContextWindow: contextWindow, + }, + }}) + return account + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + newAccount(26, []string{"low", "high"}, []string{"text", "image"}, 256_000), + newAccount(27, []string{"high", "max"}, []string{"text"}, 128_000), + }, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []string{"high"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, "high", models[0]["default_reasoning_level"]) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.EqualValues(t, 128_000, models[0]["context_window"]) +} + +func TestBuildCodexModelsManifestForGroupDoesNotAdvertiseNoneWhenAccountReasoningConflicts(t *testing.T) { + t.Parallel() + + const groupID int64 = 738 + reasoning := true + noReasoning := false + newAccount := func(id int64, metadata UpstreamModelMetadata) Account { + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"shared-model": "shared-model"}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "shared-model": metadata, + }}) + return account + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + newAccount(29, UpstreamModelMetadata{ + ID: "shared-model", Reasoning: &reasoning, + SupportedReasoningLevels: []string{"low", "high"}, + InputModalities: []string{"text"}, ContextWindow: 128_000, + }), + newAccount(30, UpstreamModelMetadata{ + ID: "shared-model", Reasoning: &noReasoning, + InputModalities: []string{"text"}, ContextWindow: 128_000, + }), + }, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + _, hasDefault := models[0]["default_reasoning_level"] + require.False(t, hasDefault) + require.Empty(t, models[0]["supported_reasoning_levels"]) +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 7998018a8c..e0715889fa 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -292,13 +292,23 @@ type configuredCodexModelDescriptor struct { MultiAgentVersion any `json:"multi_agent_version"` } +type codexModelMetadataOverride struct { + UpstreamModelMetadata + reasoningConflict bool + inputModalitiesConflict bool +} + func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescriptor { modelID = strings.TrimSpace(modelID) + noReasoningLevel := "none" descriptor := configuredCodexModelDescriptor{ - Slug: modelID, - DisplayName: modelID, - Description: "Custom model routed through Sub2API.", - SupportedReasoningLevels: []configuredCodexReasoningLevel{}, + Slug: modelID, + DisplayName: modelID, + Description: "Custom model routed through Sub2API.", + DefaultReasoningLevel: &noReasoningLevel, + SupportedReasoningLevels: []configuredCodexReasoningLevel{ + {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")}, + }, ShellType: "unified_exec", Visibility: "list", SupportedInAPI: true, @@ -606,7 +616,7 @@ func claudeCodexDisplayName(modelID string) string { // routed through a custom provider. The response is also suitable for saving // as model_catalog_json in clients that do not refresh custom-provider catalogs. func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) { - return buildCodexModelsManifest(modelIDs, nil, nil) + return buildCodexModelsManifest(modelIDs, nil, nil, nil) } // BuildCodexModelsManifestForGroup derives input capabilities from the @@ -626,7 +636,7 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup( if effectivePlatform == "" { effectivePlatform = group.Platform } - if effectivePlatform != PlatformOpenAI && effectivePlatform != PlatformGrok && effectivePlatform != PlatformComposite { + if effectivePlatform != PlatformComposite && !isConcreteRequestPlatform(effectivePlatform) { return BuildCodexModelsManifest(modelIDs) } @@ -650,7 +660,9 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup( compositeRoutes, compositeRoutesAvailable, ) + modelMetadata := make(map[string]codexModelMetadataOverride, len(modelIDs)) for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) if groupCodexModelSupportsImageInput( effectivePlatform, modelID, @@ -658,13 +670,27 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup( compositeRoutes, compositeRoutesAvailable, ) { - imageInputModels[strings.TrimSpace(modelID)] = true + imageInputModels[modelID] = true + } + if metadata, ok := groupCodexModelMetadata( + effectivePlatform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ); ok { + modelMetadata[modelID] = metadata } } - return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels) + return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels, modelMetadata) } -func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]bool, metadataModels map[string]string) ([]byte, error) { +func buildCodexModelsManifest( + modelIDs []string, + imageInputModels map[string]bool, + metadataModels map[string]string, + modelMetadata map[string]codexModelMetadataOverride, +) ([]byte, error) { seen := make(map[string]struct{}, len(modelIDs)) models := make([]configuredCodexModelDescriptor, 0, len(modelIDs)) for _, modelID := range modelIDs { @@ -688,6 +714,9 @@ func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]boo if imageInputModels[modelID] { descriptor.InputModalities = []string{"text", "image"} } + if metadata, ok := modelMetadata[modelID]; ok { + applyUpstreamModelMetadataToCodexDescriptor(&descriptor, metadata) + } models = append(models, descriptor) } return json.Marshal(struct { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index fead96a98c..99d96f0b6d 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -182,8 +182,11 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t grokNonReasoning := newConfiguredCodexModelDescriptor("grok-4.20-0309-non-reasoning") require.Equal(t, "Grok 4.20 Non Reasoning", grokNonReasoning.DisplayName) - require.Nil(t, grokNonReasoning.DefaultReasoningLevel) - require.Empty(t, grokNonReasoning.SupportedReasoningLevels) + require.NotNil(t, grokNonReasoning.DefaultReasoningLevel) + require.Equal(t, "none", *grokNonReasoning.DefaultReasoningLevel) + require.Equal(t, []configuredCodexReasoningLevel{ + {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")}, + }, grokNonReasoning.SupportedReasoningLevels) claude := newConfiguredCodexModelDescriptor("claude-opus-4-6") require.Equal(t, "Claude Opus 4.6", claude.DisplayName) @@ -203,8 +206,9 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t claudeHaiku := newConfiguredCodexModelDescriptor("claude-haiku-4-5-20251001") require.Equal(t, "Claude Haiku 4.5", claudeHaiku.DisplayName) - require.Nil(t, claudeHaiku.DefaultReasoningLevel) - require.Empty(t, claudeHaiku.SupportedReasoningLevels) + require.NotNil(t, claudeHaiku.DefaultReasoningLevel) + require.Equal(t, "none", *claudeHaiku.DefaultReasoningLevel) + require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(claudeHaiku.SupportedReasoningLevels)) gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol") require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName) @@ -238,20 +242,25 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t gpt4o := newConfiguredCodexModelDescriptor("gpt-4o") require.Equal(t, "gpt-4o", gpt4o.DisplayName) - require.Nil(t, gpt4o.DefaultReasoningLevel) - require.Empty(t, gpt4o.SupportedReasoningLevels) + require.NotNil(t, gpt4o.DefaultReasoningLevel) + require.Equal(t, "none", *gpt4o.DefaultReasoningLevel) + require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(gpt4o.SupportedReasoningLevels)) require.True(t, gpt4o.SupportsParallelToolCalls) image := newConfiguredCodexModelDescriptor("gpt-image-2") require.Equal(t, "gpt-image-2", image.DisplayName) - require.Nil(t, image.DefaultReasoningLevel) - require.Empty(t, image.SupportedReasoningLevels) + require.NotNil(t, image.DefaultReasoningLevel) + require.Equal(t, "none", *image.DefaultReasoningLevel) + require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(image.SupportedReasoningLevels)) custom := newConfiguredCodexModelDescriptor("company-coding-model") require.Equal(t, "company-coding-model", custom.DisplayName) require.Equal(t, int64(272_000), custom.ContextWindow) - require.Nil(t, custom.DefaultReasoningLevel) - require.Empty(t, custom.SupportedReasoningLevels) + require.NotNil(t, custom.DefaultReasoningLevel) + require.Equal(t, "none", *custom.DefaultReasoningLevel) + require.Equal(t, []configuredCodexReasoningLevel{ + {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")}, + }, custom.SupportedReasoningLevels) require.False(t, custom.SupportsParallelToolCalls) require.NotEmpty(t, custom.ModelMessages.InstructionsTemplate) require.Equal(t, "auto", custom.DefaultReasoningSummary) @@ -266,6 +275,40 @@ func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) [] return efforts } +// Scenario: 无推理模型可直接选中。 +func TestBuildCodexModelsManifestUsesSingleNoneReasoningChoiceForCustomModel(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{"company-coding-model"}) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "none", models[0]["default_reasoning_level"]) + levels, ok := models[0]["supported_reasoning_levels"].([]any) + require.True(t, ok) + require.Len(t, levels, 1) + firstLevel, ok := levels[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "none", firstLevel["effort"]) +} + +// Scenario: 已知推理模型保留真实档位。 +func TestBuildCodexModelsManifestKeepsKnownReasoningChoices(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{"gpt-5.6-sol"}) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "low", models[0]["default_reasoning_level"]) + levels, ok := models[0]["supported_reasoning_levels"].([]any) + require.True(t, ok) + require.Len(t, levels, 6) + firstLevel, ok := levels[0].(map[string]any) + require.True(t, ok) + require.NotEqual(t, "none", firstLevel["effort"]) +} + // Scenario: 专用图片生成模型不进入 Codex 主模型目录。 func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) { t.Parallel() @@ -679,7 +722,7 @@ func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) { require.Equal(t, int32(1), repo.calls.Load()) } -func TestBuildCodexModelsManifestForGroupSkipsCapabilityLookupForTextOnlyPlatform(t *testing.T) { +func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) { t.Parallel() repo := &countingCodexModelsAccountRepo{} @@ -691,7 +734,7 @@ func TestBuildCodexModelsManifestForGroupSkipsCapabilityLookupForTextOnlyPlatfor []string{"deepseek-v4-pro"}, ) require.NoError(t, err) - require.Equal(t, int32(0), repo.calls.Load()) + require.Equal(t, int32(1), repo.calls.Load()) models := decodeCodexManifestModels(t, body) require.Len(t, models, 1) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 957d38a8c7..511a559db5 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -20,6 +20,11 @@ import ( // Forward forwards request to OpenAI API func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) + filteredBody, filterErr := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body) + if filterErr != nil { + return nil, filterErr + } + body = filteredBody clearGrokResponsesClientToolMapping(c) clearOpenAIResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index cbf4e6e9c8..9b69914bd3 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -55,6 +55,69 @@ func buildOpenAIResponsesURLForPlatform(platform string, base string) string { return buildOpenAIResponsesURL(base) } +func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool { + if account == nil { + return false + } + if account.IsOpenAIOAuthLike() { + return true + } + if !account.IsOpenAIApiKey() { + return false + } + baseURL := strings.TrimSpace(account.GetCredential("base_url")) + return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) +} + +// Codex 0.149.0 needs a single advertised effort to directly select a visible +// non-reasoning model. Treat that catalog-only "none" value as omission for +// compatible upstreams, while preserving official OpenAI request semantics. +func filterOpenAIResponsesNoneReasoningEffortForAccount(account *Account, body []byte) ([]byte, error) { + if len(body) == 0 || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) { + return body, nil + } + + out := body + for _, path := range []string{"reasoning.effort", "reasoning_effort"} { + effort := gjson.GetBytes(out, path) + if effort.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(effort.String()), "none") { + continue + } + next, err := sjson.DeleteBytes(out, path) + if err != nil { + return body, fmt.Errorf("strip %s none placeholder: %w", path, err) + } + out = next + } + if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 { + next, err := sjson.DeleteBytes(out, "reasoning") + if err != nil { + return body, fmt.Errorf("strip empty reasoning object: %w", err) + } + out = next + } + return out, nil +} + +func deleteOpenAIResponsesNoneReasoningEffortFromObject(account *Account, body map[string]any) { + if body == nil || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) { + return + } + if effort, ok := body["reasoning_effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") { + delete(body, "reasoning_effort") + } + reasoning, ok := body["reasoning"].(map[string]any) + if !ok { + return + } + if effort, ok := reasoning["effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") { + delete(reasoning, "effort") + } + if len(reasoning) == 0 { + delete(body, "reasoning") + } +} + // normalizeDeepSeekResponsesRequestBody 适配 DeepSeek 无状态 Responses 端点: // 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端 // 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。 diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index f14a75018b..6ff334933c 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -270,3 +270,63 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) { require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) } + +func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) { + tests := []struct { + name string + account *Account + body string + wantNested bool + wantFlat bool + wantSummary bool + wantReasoning bool + }{ + { + name: "custom compatible endpoint strips none placeholders", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}}, + body: `{"reasoning":{"effort":"none"},"reasoning_effort":"NONE"}`, + wantReasoning: false, + }, + { + name: "third-party platform keeps other reasoning members", + account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, + body: `{"reasoning":{"effort":" none ","summary":"auto"}}`, + wantSummary: true, + wantReasoning: true, + }, + { + name: "non-none effort is unchanged", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}}, + body: `{"reasoning":{"effort":"high"},"reasoning_effort":"low"}`, + wantNested: true, + wantFlat: true, + wantReasoning: true, + }, + { + name: "official OpenAI API key preserves none", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + body: `{"reasoning":{"effort":"none"},"reasoning_effort":"none"}`, + wantNested: true, + wantFlat: true, + wantReasoning: true, + }, + { + name: "OpenAI OAuth preserves none", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + body: `{"reasoning":{"effort":"none"}}`, + wantNested: true, + wantReasoning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(tt.account, []byte(tt.body)) + require.NoError(t, err) + require.Equal(t, tt.wantNested, gjson.GetBytes(got, "reasoning.effort").Exists()) + require.Equal(t, tt.wantFlat, gjson.GetBytes(got, "reasoning_effort").Exists()) + require.Equal(t, tt.wantSummary, gjson.GetBytes(got, "reasoning.summary").Exists()) + require.Equal(t, tt.wantReasoning, gjson.GetBytes(got, "reasoning").Exists()) + }) + } +} diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go index 750c45e8f2..2989209202 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go @@ -55,6 +55,36 @@ func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletion require.False(t, result.Stream) } +// Scenario: 第三方无推理模型不收到兼容档位。 +func TestForwardResponses_ForceChatCompletionsOmitsNoneReasoningEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"company-coding-model","input":"hello","reasoning":{"effort":"none"},"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_none","object":"chat.completion","model":"company-coding-model","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, + )), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "company-coding-model", gjson.GetBytes(upstream.lastBody, "model").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning_effort").Exists()) + require.Nil(t, result.ReasoningEffort) +} + func TestForwardResponses_PassthroughFlagWithUnsupportedResponsesUsesAccountMapping(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 3f5d1e57b5..d13a370d42 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -115,7 +115,7 @@ func (s *OpenAIGatewayService) shouldBridgeOpenAIWSHTTP(account *Account, payloa return threshold > 0 && int64(payloadBytes) >= threshold } -func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) { +func prepareOpenAIWSHTTPBridgeBody(account *Account, payload []byte) ([]byte, error) { var body map[string]any if err := decodeOpenAIJSONUseNumber(payload, &body); err != nil { return nil, err @@ -126,6 +126,7 @@ func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) { delete(body, "type") delete(body, "generate") delete(body, "previous_response_id") + deleteOpenAIResponsesNoneReasoningEffortFromObject(account, body) body["stream"] = true return json.Marshal(body) } @@ -305,7 +306,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } responseModelObserver := &upstreamResponseModelObserver{} - body, err := prepareOpenAIWSHTTPBridgeBody(payload) + body, err := prepareOpenAIWSHTTPBridgeBody(account, payload) if err != nil { return nil, fmt.Errorf("prepare http bridge body: %w", err) } @@ -826,7 +827,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, seedPayload, currentPayload []byte, originalModel string) (string, error) { - body, err := prepareOpenAIWSHTTPBridgeBody(seedPayload) + body, err := prepareOpenAIWSHTTPBridgeBody(account, seedPayload) if err != nil { return "", err } diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 7824591e07..222f0ed5eb 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -31,7 +31,7 @@ func TestResolveOpenAIWSClientFirstMessageTimeout(t *testing.T) { } func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { - body, err := prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`)) + body, err := prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`)) require.NoError(t, err) require.False(t, gjson.GetBytes(body, "type").Exists()) require.False(t, gjson.GetBytes(body, "generate").Exists()) @@ -40,10 +40,26 @@ func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { require.True(t, gjson.GetBytes(body, "stream").Bool()) require.Equal(t, "hi", gjson.GetBytes(body, "input").String()) require.Equal(t, "900719925474099312345", gjson.GetBytes(body, "sequence").Raw) - _, err = prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create"}{"trailing":true}`)) + _, err = prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create"}{"trailing":true}`)) require.Error(t, err) } +func TestPrepareOpenAIWSHTTPBridgeBodyStripsNoneReasoningForCompatibleEndpoint(t *testing.T) { + payload := []byte(`{"type":"response.create","model":"company-coding-model","reasoning":{"effort":"none"},"input":"hi"}`) + compatible := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://compat.example/v1", + }} + + body, err := prepareOpenAIWSHTTPBridgeBody(compatible, payload) + require.NoError(t, err) + require.False(t, gjson.GetBytes(body, "reasoning.effort").Exists()) + require.False(t, gjson.GetBytes(body, "reasoning").Exists()) + + officialBody, err := prepareOpenAIWSHTTPBridgeBody(&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, payload) + require.NoError(t, err) + require.Equal(t, "none", gjson.GetBytes(officialBody, "reasoning.effort").String()) +} + func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyAdaptsClientTools(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index df926b0297..164b9fe816 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -3,18 +3,127 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "io" + "log/slog" "net/http" + "net/url" "sort" "strings" + "time" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" ) -const upstreamModelsBodyLimit int64 = 8 << 20 +const ( + upstreamModelsBodyLimit int64 = 8 << 20 + modelsDevRegistryURL = "https://models.dev/api.json" + modelsDevRegistryTTL = 6 * time.Hour + UpstreamModelMetadataExtraKey = "upstream_model_metadata" + UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete" +) + +type UpstreamModelMetadata struct { + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Reasoning *bool `json:"reasoning,omitempty"` + DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"` + SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"` + InputModalities []string `json:"input_modalities,omitempty"` + ContextWindow int64 `json:"context_window,omitempty"` + MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` +} + +type UpstreamModelMetadataSnapshot struct { + Source string `json:"source"` + SyncedAt string `json:"synced_at"` + Models map[string]UpstreamModelMetadata `json:"models"` +} + +type UpstreamModelCatalog struct { + Models []string `json:"models"` + Metadata map[string]UpstreamModelMetadata `json:"metadata,omitempty"` + Warnings []UpstreamModelSyncWarning `json:"warnings,omitempty"` +} + +type UpstreamModelSyncWarning struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type modelsDevProvider struct { + ID string `json:"id"` + Name string `json:"name"` + API string `json:"api"` + Models map[string]modelsDevModel `json:"models"` +} + +type modelsDevModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Reasoning *bool `json:"reasoning"` + ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"` + Modalities modelsDevModalities `json:"modalities"` + Limit modelsDevLimit `json:"limit"` +} + +type modelsDevReasoningOption struct { + Type string `json:"type"` + Values []any `json:"values"` +} + +type modelsDevModalities struct { + Input []string `json:"input"` + Output []string `json:"output"` +} + +type modelsDevLimit struct { + Context int64 `json:"context"` + Output int64 `json:"output"` +} + +func (a *Account) SetUpstreamModelMetadataSnapshot(snapshot UpstreamModelMetadataSnapshot) { + if a == nil { + return + } + if a.Extra == nil { + a.Extra = make(map[string]any) + } + a.Extra[UpstreamModelMetadataExtraKey] = snapshot +} + +func (a *Account) GetUpstreamModelMetadataSnapshot() *UpstreamModelMetadataSnapshot { + if a == nil || a.Extra == nil { + return nil + } + raw, ok := a.Extra[UpstreamModelMetadataExtraKey] + if !ok || raw == nil { + return nil + } + body, err := json.Marshal(raw) + if err != nil { + return nil + } + var snapshot UpstreamModelMetadataSnapshot + if err := json.Unmarshal(body, &snapshot); err != nil || len(snapshot.Models) == 0 { + return nil + } + return &snapshot +} + +func (a *Account) GetUpstreamModelMetadata(modelID string) (UpstreamModelMetadata, bool) { + snapshot := a.GetUpstreamModelMetadataSnapshot() + if snapshot == nil { + return UpstreamModelMetadata{}, false + } + metadata, ok := snapshot.Models[strings.TrimSpace(modelID)] + return metadata, ok +} // UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping. type UpstreamModelSyncErrorKind string @@ -26,13 +135,16 @@ const ( UpstreamModelSyncErrorUnsupported UpstreamModelSyncErrorKind = "unsupported" // UpstreamModelSyncErrorUpstream means the configured upstream failed or returned an unusable response. UpstreamModelSyncErrorUpstream UpstreamModelSyncErrorKind = "upstream" + // UpstreamModelSyncErrorInternal means local persistence or service state failed after a valid upstream response. + UpstreamModelSyncErrorInternal UpstreamModelSyncErrorKind = "internal" ) // UpstreamModelSyncError keeps internal failure details wrapped while exposing a safe client message. type UpstreamModelSyncError struct { - Kind UpstreamModelSyncErrorKind - Message string - Err error + Kind UpstreamModelSyncErrorKind + Message string + StatusCode int + Err error } func (e *UpstreamModelSyncError) Error() string { @@ -72,48 +184,431 @@ func newUpstreamModelSyncUpstreamError(message string, err error) error { return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorUpstream, Message: message, Err: err} } -// FetchUpstreamSupportedModels fetches the live model list from the account's upstream API format. +func newUpstreamModelSyncInternalError(message string, err error) error { + return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorInternal, Message: message, Err: err} +} + +// FetchUpstreamSupportedModels fetches only live model IDs. The admin sync path +// uses SyncUpstreamModelCatalog so capability metadata can also be persisted. func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, account *Account) ([]string, error) { + models, _, err := s.fetchUpstreamModelList(ctx, account) + return models, err +} + +// 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. +func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) { + models, body, err := s.fetchUpstreamModelList(ctx, account) + if err != nil { + configuredModels := configuredUpstreamModelsForCapabilitySync(account) + if !upstreamModelListEndpointUnsupported(err) || len(configuredModels) == 0 { + return nil, err + } + models = configuredModels + body = nil + slog.Info("upstream model list endpoint unavailable; using configured models for capability sync", + "account_id", upstreamModelSyncAccountID(account), + "platform", upstreamModelSyncPlatform(account), + "status_code", upstreamModelSyncStatusCode(err), + "model_count", len(models), + ) + } + catalog := &UpstreamModelCatalog{Models: models, Metadata: make(map[string]UpstreamModelMetadata)} + if len(body) > 0 { + _, directMetadata, parseErr := extractUpstreamModelCatalog(body, account != nil && account.IsGrok()) + if parseErr == nil { + catalog.Metadata = directMetadata + } + } + + source := "upstream" + metadataIncomplete := upstreamCatalogNeedsRegistry(models, catalog.Metadata) + if metadataIncomplete { + if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, models); registryErr == nil { + for modelID, fallback := range registryMetadata { + current := catalog.Metadata[modelID] + merged, changed := mergeUpstreamModelMetadata(current, fallback) + catalog.Metadata[modelID] = merged + if changed { + source = "models.dev" + } + } + } else { + slog.Warn("upstream model capability metadata enrichment failed", + "account_id", upstreamModelSyncAccountID(account), + "platform", upstreamModelSyncPlatform(account), + "error", registryErr, + ) + } + } + + 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 + } + if len(catalog.Metadata) == 0 || account == nil || account.ID <= 0 || s.accountRepo == nil { + return catalog, nil + } + 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 +} + +func upstreamModelSyncStatusCode(err error) int { + var syncErr *UpstreamModelSyncError + if errors.As(err, &syncErr) { + return syncErr.StatusCode + } + return 0 +} + +func upstreamModelListEndpointUnsupported(err error) bool { + statusCode := upstreamModelSyncStatusCode(err) + return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed +} + +func configuredUpstreamModelsForCapabilitySync(account *Account) []string { + if account == nil { + return nil + } + models := make([]string, 0) + for _, mappedModel := range account.GetModelMapping() { + mappedModel = strings.TrimSpace(mappedModel) + if mappedModel == "" || strings.Contains(mappedModel, "*") { + continue + } + models = append(models, mappedModel) + } + return dedupeAndSortModelIDs(models) +} + +func upstreamModelSyncAccountID(account *Account) int64 { + if account == nil { + return 0 + } + return account.ID +} + +func upstreamModelSyncPlatform(account *Account) string { + if account == nil { + return "" + } + return account.Platform +} + +func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamModelMetadata) bool { + 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 { + return true + } + } + return false +} + +func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool { + return strings.TrimSpace(metadata.DisplayName) != "" || + strings.TrimSpace(metadata.Description) != "" || + metadata.Reasoning != nil || + len(metadata.SupportedReasoningLevels) > 0 || + len(metadata.InputModalities) > 0 || + metadata.ContextWindow > 0 || + metadata.MaxOutputTokens > 0 +} + +func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (UpstreamModelMetadata, bool) { + merged := primary + changed := false + if strings.TrimSpace(merged.ID) == "" && strings.TrimSpace(fallback.ID) != "" { + merged.ID = strings.TrimSpace(fallback.ID) + changed = true + } + if strings.TrimSpace(merged.DisplayName) == "" && strings.TrimSpace(fallback.DisplayName) != "" { + merged.DisplayName = strings.TrimSpace(fallback.DisplayName) + changed = true + } + if strings.TrimSpace(merged.Description) == "" && strings.TrimSpace(fallback.Description) != "" { + merged.Description = strings.TrimSpace(fallback.Description) + changed = true + } + if merged.Reasoning == nil && fallback.Reasoning != nil { + reasoning := *fallback.Reasoning + merged.Reasoning = &reasoning + changed = true + } + if strings.TrimSpace(merged.DefaultReasoningLevel) == "" && strings.TrimSpace(fallback.DefaultReasoningLevel) != "" { + merged.DefaultReasoningLevel = strings.TrimSpace(fallback.DefaultReasoningLevel) + changed = true + } + if len(merged.SupportedReasoningLevels) == 0 && len(fallback.SupportedReasoningLevels) > 0 { + merged.SupportedReasoningLevels = append([]string(nil), fallback.SupportedReasoningLevels...) + changed = true + } + if len(merged.InputModalities) == 0 && len(fallback.InputModalities) > 0 { + merged.InputModalities = append([]string(nil), fallback.InputModalities...) + changed = true + } + if merged.ContextWindow <= 0 && fallback.ContextWindow > 0 { + merged.ContextWindow = fallback.ContextWindow + changed = true + } + if merged.MaxOutputTokens <= 0 && fallback.MaxOutputTokens > 0 { + merged.MaxOutputTokens = fallback.MaxOutputTokens + changed = true + } + return merged, changed +} + +func (s *AccountTestService) fetchModelsDevMetadata( + ctx context.Context, + account *Account, + modelIDs []string, +) (map[string]UpstreamModelMetadata, error) { + if s == nil || s.httpUpstream == nil || account == nil { + return nil, fmt.Errorf("model metadata registry is not configured") + } + registry, err := s.fetchModelsDevRegistry(ctx, account) + if err != nil { + return nil, err + } + provider, ok := matchModelsDevProvider(registry, upstreamModelRegistryBaseURL(account)) + if !ok { + return nil, fmt.Errorf("no model metadata provider matches account base URL") + } + + metadata := make(map[string]UpstreamModelMetadata) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + model, found := provider.Models[modelID] + if !found { + for candidateID, candidate := range provider.Models { + if strings.EqualFold(strings.TrimSpace(candidateID), modelID) || strings.EqualFold(strings.TrimSpace(candidate.ID), modelID) { + model = candidate + found = true + break + } + } + } + if !found { + continue + } + entry := upstreamMetadataFromModelsDevModel(modelID, model) + if upstreamModelMetadataIsUseful(entry) { + metadata[modelID] = entry + } + } + return metadata, nil +} + +func (s *AccountTestService) fetchModelsDevRegistry(ctx context.Context, account *Account) (map[string]modelsDevProvider, error) { + now := time.Now() + s.modelMetadataRegistryMu.Lock() + if len(s.modelMetadataRegistry) > 0 && now.Sub(s.modelMetadataRegistryAt) < modelsDevRegistryTTL { + cached := s.modelMetadataRegistry + s.modelMetadataRegistryMu.Unlock() + return cached, nil + } + s.modelMetadataRegistryMu.Unlock() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsDevRegistryURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := s.doUpstreamModelsRequest(req, upstreamModelsProxyURL(account), account) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("model metadata registry returned HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > upstreamModelsBodyLimit { + return nil, fmt.Errorf("model metadata registry response exceeds %d bytes", upstreamModelsBodyLimit) + } + var registry map[string]modelsDevProvider + if err := json.Unmarshal(body, ®istry); err != nil { + return nil, fmt.Errorf("parse model metadata registry: %w", err) + } + if len(registry) == 0 { + return nil, fmt.Errorf("model metadata registry is empty") + } + + s.modelMetadataRegistryMu.Lock() + s.modelMetadataRegistry = registry + s.modelMetadataRegistryAt = now + s.modelMetadataRegistryMu.Unlock() + return registry, nil +} + +func upstreamMetadataFromModelsDevModel(modelID string, model modelsDevModel) UpstreamModelMetadata { + levels := reasoningLevelsFromModelsDevOptions(model.ReasoningOptions) + reasoning := model.Reasoning + if reasoning == nil && len(levels) > 0 { + inferred := true + reasoning = &inferred + } + metadata := UpstreamModelMetadata{ + ID: strings.TrimSpace(modelID), + DisplayName: strings.TrimSpace(model.Name), + Description: strings.TrimSpace(model.Description), + Reasoning: reasoning, + SupportedReasoningLevels: levels, + InputModalities: normalizeCodexInputModalities(model.Modalities.Input), + ContextWindow: model.Limit.Context, + MaxOutputTokens: model.Limit.Output, + } + if len(levels) > 0 { + metadata.DefaultReasoningLevel = levels[0] + } + if strings.TrimSpace(model.ID) != "" { + metadata.ID = strings.TrimSpace(model.ID) + } + return metadata +} + +func reasoningLevelsFromModelsDevOptions(options []modelsDevReasoningOption) []string { + levels := make([]string, 0) + for _, option := range options { + if !strings.EqualFold(strings.TrimSpace(option.Type), "effort") { + continue + } + for _, value := range option.Values { + if value == nil { + levels = append(levels, "none") + continue + } + if effort, ok := value.(string); ok { + levels = append(levels, effort) + } + } + } + return normalizeReasoningLevels(levels) +} + +func upstreamModelRegistryBaseURL(account *Account) string { + if account == nil { + return "" + } + switch { + case account.IsOpenAI() || account.IsCNProvider(): + return account.GetOpenAIFormatBaseURL() + case account.IsGrok(): + return account.GetGrokBaseURL() + case account.IsGemini(): + return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) + case account.IsAnthropic(): + return account.GetBaseURL() + case account.Platform == PlatformAntigravity: + return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) + default: + return strings.TrimSpace(account.GetCredential("base_url")) + } +} + +func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { + accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL) + if accountBaseURL == "" { + return modelsDevProvider{}, false + } + var best modelsDevProvider + bestScore := -1 + for _, provider := range registry { + providerBaseURL := normalizeModelRegistryBaseURL(provider.API) + if providerBaseURL == "" { + continue + } + if accountBaseURL != providerBaseURL && + !strings.HasPrefix(accountBaseURL, providerBaseURL+"/") && + !strings.HasPrefix(providerBaseURL, accountBaseURL+"/") { + continue + } + if len(providerBaseURL) > bestScore { + best = provider + bestScore = len(providerBaseURL) + } + } + return best, bestScore >= 0 +} + +func normalizeModelRegistryBaseURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + path := strings.TrimRight(parsed.Path, "/") + if strings.HasSuffix(strings.ToLower(path), "/models") { + path = strings.TrimRight(path[:len(path)-len("/models")], "/") + } + return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host) + path +} + +func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account *Account) ([]string, []byte, error) { if s == nil { - return nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil) + return nil, nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil) } if account == nil { - return nil, newUpstreamModelSyncConfigError("Account is required", nil) + return nil, nil, newUpstreamModelSyncConfigError("Account is required", nil) } if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey { - return s.fetchAntigravityOAuthUpstreamModels(ctx, account) + models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account) + return models, nil, err } if s.httpUpstream == nil { - return nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil) + return nil, nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil) } req, err := s.buildUpstreamModelsRequest(ctx, account) if err != nil { - return nil, err + return nil, nil, err } proxyURL := upstreamModelsProxyURL(account) resp, err := s.doUpstreamModelsRequest(req, proxyURL, account) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1)) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err) } if int64(len(body)) > upstreamModelsBodyLimit { - return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", upstreamModelsBodyLimit)) + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", upstreamModelsBodyLimit)) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, newUpstreamModelSyncUpstreamError( - fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode), - fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode), - ) + return nil, nil, &UpstreamModelSyncError{ + Kind: UpstreamModelSyncErrorUpstream, + Message: fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode), + StatusCode: resp.StatusCode, + Err: fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode), + } } extractModels := extractUpstreamModelIDs @@ -122,13 +617,13 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a } models, err := extractModels(body) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err) } if len(models) == 0 { - return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) } - return models, nil + return models, body, nil } func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { @@ -498,6 +993,7 @@ func buildGeminiModelsURL(base string) string { type upstreamModelEntry struct { ID string `json:"id"` + Slug string `json:"slug"` Model string `json:"model"` ModelID string `json:"modelId"` ModelIDSnake string `json:"model_id"` @@ -507,12 +1003,29 @@ type upstreamModelEntry struct { type upstreamModelEntryMetadata struct { ID string `json:"id"` + Slug string `json:"slug"` Model string `json:"model"` ModelID string `json:"modelId"` ModelIDSnake string `json:"model_id"` Name string `json:"name"` } +type upstreamModelCapabilityEntry struct { + upstreamModelEntry + DisplayName string `json:"display_name"` + Description string `json:"description"` + Reasoning *bool `json:"reasoning"` + DefaultReasoningLevel string `json:"default_reasoning_level"` + SupportedReasoningLevels []json.RawMessage `json:"supported_reasoning_levels"` + ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"` + InputModalities []string `json:"input_modalities"` + Modalities modelsDevModalities `json:"modalities"` + ContextWindow int64 `json:"context_window"` + MaxContextWindow int64 `json:"max_context_window"` + MaxOutputTokens int64 `json:"max_output_tokens"` + Limit modelsDevLimit `json:"limit"` +} + func extractUpstreamModelIDs(body []byte) ([]string, error) { return extractUpstreamModelIDsWithSelector(body, upstreamModelEntryID) } @@ -521,6 +1034,166 @@ func extractGrokUpstreamModelIDs(body []byte) ([]string, error) { return extractUpstreamModelIDsWithSelector(body, grokUpstreamModelEntryID) } +func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]UpstreamModelMetadata, error) { + entries, err := extractUpstreamModelRawEntries(body) + if err != nil { + return nil, nil, err + } + selectID := upstreamModelEntryID + if grok { + selectID = grokUpstreamModelEntryID + } + + models := make([]string, 0, len(entries)) + metadata := make(map[string]UpstreamModelMetadata) + for _, raw := range entries { + var capability upstreamModelCapabilityEntry + if err := json.Unmarshal(raw, &capability); err != nil { + continue + } + modelID := strings.TrimSpace(selectID(capability.upstreamModelEntry)) + if modelID == "" { + continue + } + models = append(models, modelID) + entry := upstreamMetadataFromCapabilityEntry(modelID, capability) + if upstreamModelMetadataIsUseful(entry) { + metadata[modelID] = entry + } + } + return dedupeAndSortModelIDs(models), metadata, nil +} + +func extractUpstreamModelRawEntries(body []byte) ([]json.RawMessage, error) { + var response struct { + Data []json.RawMessage `json:"data"` + Models []json.RawMessage `json:"models"` + } + if err := json.Unmarshal(body, &response); err == nil && (response.Data != nil || response.Models != nil) { + entries := make([]json.RawMessage, 0, len(response.Data)+len(response.Models)) + entries = append(entries, response.Data...) + entries = append(entries, response.Models...) + return entries, nil + } + var entries []json.RawMessage + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("parse upstream model catalog: %w", err) + } + return entries, nil +} + +func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapabilityEntry) UpstreamModelMetadata { + levels := reasoningLevelsFromRawEntries(entry.SupportedReasoningLevels) + if len(levels) == 0 { + levels = reasoningLevelsFromModelsDevOptions(entry.ReasoningOptions) + } + reasoning := entry.Reasoning + if reasoning == nil && len(levels) > 0 { + inferred := len(levels) != 1 || levels[0] != "none" + reasoning = &inferred + } + modalities := entry.InputModalities + if len(modalities) == 0 { + modalities = entry.Modalities.Input + } + contextWindow := entry.ContextWindow + if contextWindow <= 0 { + contextWindow = entry.MaxContextWindow + } + if contextWindow <= 0 { + contextWindow = entry.Limit.Context + } + maxOutputTokens := entry.MaxOutputTokens + if maxOutputTokens <= 0 { + maxOutputTokens = entry.Limit.Output + } + defaultReasoningLevel := normalizeReasoningLevel(entry.DefaultReasoningLevel) + if defaultReasoningLevel == "" && len(levels) > 0 { + defaultReasoningLevel = levels[0] + } + displayName := strings.TrimSpace(entry.DisplayName) + if displayName == "" && strings.TrimSpace(entry.Name) != "" && strings.TrimSpace(entry.Name) != modelID { + displayName = strings.TrimSpace(entry.Name) + } + return UpstreamModelMetadata{ + ID: modelID, + DisplayName: displayName, + Description: strings.TrimSpace(entry.Description), + Reasoning: reasoning, + DefaultReasoningLevel: defaultReasoningLevel, + SupportedReasoningLevels: levels, + InputModalities: normalizeCodexInputModalities(modalities), + ContextWindow: contextWindow, + MaxOutputTokens: maxOutputTokens, + } +} + +func reasoningLevelsFromRawEntries(entries []json.RawMessage) []string { + levels := make([]string, 0, len(entries)) + for _, raw := range entries { + var effort string + if err := json.Unmarshal(raw, &effort); err == nil { + levels = append(levels, effort) + continue + } + var level struct { + Effort string `json:"effort"` + } + if err := json.Unmarshal(raw, &level); err == nil { + levels = append(levels, level.Effort) + } + } + return normalizeReasoningLevels(levels) +} + +func normalizeReasoningLevels(levels []string) []string { + seen := make(map[string]struct{}, len(levels)) + normalized := make([]string, 0, len(levels)) + for _, level := range levels { + level = normalizeReasoningLevel(level) + if level == "" { + continue + } + if _, exists := seen[level]; exists { + continue + } + seen[level] = struct{}{} + normalized = append(normalized, level) + } + return normalized +} + +func normalizeReasoningLevel(level string) string { + level = strings.ToLower(strings.TrimSpace(level)) + switch level { + case "off", "disabled": + return "none" + case "extra-high", "extra_high": + return "xhigh" + case "none", "minimal", "low", "medium", "high", "xhigh", "max": + return level + default: + return "" + } +} + +func normalizeCodexInputModalities(modalities []string) []string { + seen := make(map[string]struct{}, len(modalities)) + normalized := make([]string, 0, len(modalities)) + for _, modality := range modalities { + modality = strings.ToLower(strings.TrimSpace(modality)) + if modality != "text" && modality != "image" { + continue + } + if _, exists := seen[modality]; exists { + continue + } + seen[modality] = struct{}{} + normalized = append(normalized, modality) + } + return normalized +} + func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamModelEntry) string) ([]string, error) { var response struct { Data []upstreamModelEntry `json:"data"` @@ -561,6 +1234,9 @@ func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamMode func upstreamModelEntryID(entry upstreamModelEntry) string { modelID := strings.TrimSpace(entry.ID) + if modelID == "" { + modelID = strings.TrimSpace(entry.Slug) + } if modelID == "" { modelID = strings.TrimSpace(entry.Name) } @@ -573,6 +1249,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string { entry.ModelID, entry.ModelIDSnake, entry.ID, + entry.Slug, } if len(entry.Meta) > 0 { var meta upstreamModelEntryMetadata @@ -582,6 +1259,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string { meta.ModelID, meta.ModelIDSnake, meta.ID, + meta.Slug, meta.Name, ) } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index 031d09b574..4ee3046062 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "io" "net/http" @@ -13,6 +14,28 @@ import ( "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{ @@ -342,6 +365,336 @@ func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) { 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": "", + }, + }, + } + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + 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"}], + "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"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, metadata.InputModalities) + require.Equal(t, int64(256_000), metadata.ContextWindow) +} + +// 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") +} + func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) { t.Parallel() diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 73bfd29af3..d694bf1333 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -541,6 +541,25 @@ export async function getAvailableModels(id: number): Promise { export interface SyncUpstreamModelsResult { models: string[] + metadata?: Record + warnings?: UpstreamModelSyncWarning[] +} + +export interface UpstreamModelSyncWarning { + code: string + message: string +} + +export interface UpstreamModelMetadata { + id: string + display_name?: string + description?: string + reasoning?: boolean + default_reasoning_level?: string + supported_reasoning_levels?: string[] + input_modalities?: string[] + context_window?: number + max_output_tokens?: number } /** diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 2c9621f17f..f2264928ca 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1402,7 +1402,12 @@
- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -1884,7 +1889,12 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ t('admin.accounts.supportsAllModels') }} @@ -2220,7 +2230,12 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -4093,6 +4108,7 @@ const modelMappings = ref([]) const openAICompactModelMappings = ref([]) const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const allowedModels = ref([]) +const upstreamModelsPreviewed = ref(false) const DEFAULT_POOL_MODE_RETRY_COUNT = 3 const MAX_POOL_MODE_RETRY_COUNT = 10 const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429] @@ -4579,6 +4595,7 @@ watch( } // Clear model-related settings allowedModels.value = [] + upstreamModelsPreviewed.value = false modelMappings.value = [] // Antigravity: 默认使用映射模式并填充默认映射 if (newPlatform === 'antigravity') { @@ -4970,6 +4987,16 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => { submitting.value = true try { const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload)) + if (upstreamModelsPreviewed.value) { + try { + const result = await adminAPI.accounts.syncUpstreamModels(account.id) + if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete')) + } + } catch { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsFailed')) + } + } if ( payload.type === 'apikey' && payload.upstream_billing_probe_enabled === true @@ -5110,6 +5137,7 @@ const resetForm = () => { grokOAuth.resetState() oauthFlowRef.value?.reset() antigravityMixedChannelConfirmed.value = false + upstreamModelsPreviewed.value = false clearMixedChannelDialog() } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index d1cb92df59..240e96fcb9 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -4064,6 +4064,10 @@ const syncAntigravityUpstreamModels = async () => { } } + if (result.warnings?.some((warning) => warning.code === 'upstream_model_metadata_incomplete')) { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete')) + return + } if (addedCount > 0) { appStore.showSuccess(t('admin.accounts.syncUpstreamModelsSuccess', { count: addedCount, total: upstreamModels.length })) } else { diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index b6086c6d6b..a3b1309fbf 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -172,6 +172,7 @@ const props = defineProps<{ const emit = defineEmits<{ 'update:modelValue': [value: string[]] + 'upstream-synced': [] }>() const appStore = useAppStore() @@ -312,6 +313,10 @@ const syncUpstreamModels = async () => { return } + if (!props.accountId) { + emit('upstream-synced') + } + const newModels = [...props.modelValue] let addedCount = 0 for (const model of upstreamModels) { @@ -322,6 +327,10 @@ const syncUpstreamModels = async () => { } emit('update:modelValue', newModels) + if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete')) + return + } if (addedCount > 0) { appStore.showSuccess(t('admin.accounts.syncUpstreamModelsSuccess', { count: addedCount, total: upstreamModels.length })) } else { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index e5a0c8c9c1..9288dfda16 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -5,12 +5,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { createAccountMock, probeUpstreamBillingMock, + syncUpstreamModelsMock, + showWarningMock, importCodexSessionMock, createOpenAICodexPATMock, authIsSimpleMode, } = vi.hoisted(() => ({ createAccountMock: vi.fn(), probeUpstreamBillingMock: vi.fn(), + syncUpstreamModelsMock: vi.fn(), + showWarningMock: vi.fn(), importCodexSessionMock: vi.fn(), createOpenAICodexPATMock: vi.fn(), authIsSimpleMode: { value: true }, @@ -20,7 +24,7 @@ vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn(), - showWarning: vi.fn(), + showWarning: showWarningMock, }), })) @@ -37,6 +41,7 @@ vi.mock('@/api/admin', () => ({ accounts: { create: createAccountMock, probeUpstreamBilling: probeUpstreamBillingMock, + syncUpstreamModels: syncUpstreamModelsMock, checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }), importCodexSession: importCodexSessionMock, createOpenAICodexPAT: createOpenAICodexPATMock, @@ -120,8 +125,8 @@ const ModelWhitelistSelectorStub = defineComponent({ platform: String, syncCredentials: Object, }, - emits: ['update:modelValue'], - template: '

', + emits: ['update:modelValue', 'upstream-synced'], + template: '', }) function mountModal(groups: any[] = []) { @@ -190,6 +195,8 @@ describe('CreateAccountModal OpenAI long-context billing', () => { authIsSimpleMode.value = true createAccountMock.mockReset().mockResolvedValue({ id: 42, platform: 'openai', type: 'apikey' }) probeUpstreamBillingMock.mockReset().mockResolvedValue({}) + syncUpstreamModelsMock.mockReset().mockResolvedValue({ models: [], metadata: {} }) + showWarningMock.mockReset() importCodexSessionMock.mockReset().mockResolvedValue({ created: 1, updated: 0, @@ -236,6 +243,39 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + it('persists upstream model metadata after creating an account from preview', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledOnce() + expect(syncUpstreamModelsMock).toHaveBeenCalledWith(42) + }) + + it('warns when post-create capability metadata remains incomplete', async () => { + syncUpstreamModelsMock.mockResolvedValue({ + models: ['x-preview-f-free'], + warnings: [{ code: 'upstream_model_metadata_incomplete', message: 'metadata incomplete' }], + }) + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(showWarningMock).toHaveBeenCalledWith( + 'admin.accounts.syncUpstreamModelsMetadataIncomplete' + ) + }) + // namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平 it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => { const wrapper = mountModal() diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index 51f18611cc..928ab6e721 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -1,7 +1,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' -const copyToClipboard = vi.fn().mockResolvedValue(true) +const { + copyToClipboard, + showError, + showSuccess, + showInfo, + showWarning, + syncUpstreamModels, + syncUpstreamModelsPreview +} = vi.hoisted(() => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), + showError: vi.fn(), + showSuccess: vi.fn(), + showInfo: vi.fn(), + showWarning: vi.fn(), + syncUpstreamModels: vi.fn(), + syncUpstreamModelsPreview: vi.fn() +})) vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') @@ -15,12 +31,20 @@ vi.mock('vue-i18n', async () => { vi.mock('@/stores/app', () => ({ useAppStore: () => ({ - showError: vi.fn(), - showSuccess: vi.fn(), - showInfo: vi.fn() + showError, + showSuccess, + showInfo, + showWarning }) })) +vi.mock('@/api/admin/accounts', () => ({ + accountsAPI: { + syncUpstreamModels, + syncUpstreamModelsPreview + } +})) + vi.mock('@/composables/useClipboard', () => ({ useClipboard: () => ({ copyToClipboard @@ -29,11 +53,12 @@ vi.mock('@/composables/useClipboard', () => ({ import ModelWhitelistSelector from '../ModelWhitelistSelector.vue' -function mountSelector() { +function mountSelector(props: Record = {}) { return mount(ModelWhitelistSelector, { props: { modelValue: [], - platform: 'openai' + platform: 'openai', + ...props, }, global: { stubs: { @@ -58,6 +83,12 @@ function findModelRow(wrapper: ReturnType, modelId: string describe('ModelWhitelistSelector', () => { beforeEach(() => { copyToClipboard.mockClear() + showError.mockReset() + showSuccess.mockReset() + showInfo.mockReset() + showWarning.mockReset() + syncUpstreamModels.mockReset() + syncUpstreamModelsPreview.mockReset() }) it('copies a model ID without selecting the model', async () => { @@ -86,4 +117,71 @@ describe('ModelWhitelistSelector', () => { expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-5.6-sol']]]) expect(copyToClipboard).not.toHaveBeenCalled() }) + + it('warns when model IDs sync but capability metadata is incomplete', async () => { + syncUpstreamModels.mockResolvedValue({ + models: ['x-preview-f-free'], + warnings: [ + { + code: 'upstream_model_metadata_incomplete', + message: 'Model IDs were synced, but capability metadata could not be updated.' + } + ] + }) + 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([[['x-preview-f-free']]]) + expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataIncomplete') + expect(showSuccess).not.toHaveBeenCalled() + }) + + it('reports a successful preview so account creation can persist metadata', async () => { + syncUpstreamModelsPreview.mockResolvedValue({ + models: ['x-preview-f-free'], + metadata: { + 'x-preview-f-free': { + id: 'x-preview-f-free', + reasoning: true, + supported_reasoning_levels: ['low', 'high', 'max'], + }, + }, + }) + const wrapper = mountSelector({ + syncCredentials: { + platform: 'openai', + type: 'apikey', + base_url: 'https://opencode.ai/zen/v1', + api_key: 'test-key', + }, + }) + const syncButton = wrapper + .findAll('button') + .find(button => button.text() === 'admin.accounts.syncUpstreamModels') + + expect(syncButton).toBeDefined() + await syncButton?.trigger('click') + await flushPromises() + + expect(syncUpstreamModelsPreview).toHaveBeenCalledOnce() + expect(wrapper.emitted('upstream-synced')).toEqual([[]]) + expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]]) + }) }) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 3c126b78b0..e119f76d7d 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -743,6 +743,8 @@ export default { syncUpstreamModelsEmpty: 'Upstream returned no models to sync', 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.', 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 923776d259..f73f357ec0 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -811,6 +811,7 @@ export default { syncUpstreamModelsEmpty: '上游没有返回可同步的模型', syncUpstreamModelsFailed: '同步上游模型失败', syncUpstreamModelsError: '同步上游模型失败:{message}', + syncUpstreamModelsMetadataIncomplete: '模型 ID 已同步,但能力元数据不完整,能力信息未更新。', clearAllModels: '清除所有模型', customModelName: '自定义模型名称', enterCustomModelName: '输入自定义模型名称',