diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 4d65a6aeb8..71588741a1 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1413,6 +1413,14 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool return true } } + normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking")) + if normalizedClaudeModel != model { + for _, pattern := range availablePatterns { + if pattern == normalizedClaudeModel { + return true + } + } + } return false } @@ -1434,14 +1442,7 @@ func defaultModelIDsForPlatform(platform string) []string { } return ids case service.PlatformAnthropic: - ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels())) - for _, model := range claude.DefaultModels { - ids = append(ids, model.ID) - } - for _, model := range antigravity.DefaultModels() { - ids = append(ids, model.ID) - } - return mergeModelIDs(ids, nil) + return claude.DefaultModelIDs() case service.PlatformGrok: return xai.DefaultModelIDs() case service.PlatformComposite: diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 15b43d62de..3ca5776069 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -86,6 +86,16 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) { require.Contains(t, compositeIDs, antigravityIDs[0]) } +// Scenario: Anthropic defaults contain only Claude while Antigravity keeps its own Gemini models. +func TestDefaultModelIDsForAnthropicExcludeAntigravityGemini(t *testing.T) { + anthropicIDs := defaultModelIDsForPlatform(service.PlatformAnthropic) + require.Contains(t, anthropicIDs, "claude-opus-4-6") + require.NotContains(t, anthropicIDs, "gemini-2.5-flash") + + antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity) + require.Contains(t, antigravityIDs, "gemini-2.5-flash") +} + // Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list. func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) { tests := []struct { @@ -433,6 +443,62 @@ func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) { require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data)) } +// Scenario: a Composite group with only Anthropic accounts must not inherit Antigravity Gemini defaults. +func TestGatewayCodexModels_CompositeAnthropicDoesNotAdvertiseAntigravityDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(64) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformAnthropic}}, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := codexModelSlugsForTest(got.Models) + require.Contains(t, slugs, "claude-opus-4-6") + require.NotContains(t, slugs, "gemini-2.5-flash") +} + +// Scenario: Antigravity retains its own Claude and Gemini defaults inside Composite groups. +func TestGatewayModels_CompositeAntigravityAdvertisesAntigravityDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(65) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformAntigravity}}, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + ids := modelIDsForTest(got.Data) + require.Contains(t, ids, "claude-opus-4-6") + require.Contains(t, ids, "gemini-2.5-flash") +} + func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 664b6bf08b..a8834bc403 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -395,6 +395,61 @@ func TestGatewayService_SelectAccountForModelWithPlatform_Anthropic(t *testing.T require.Equal(t, PlatformAnthropic, acc.Platform, "应只返回 anthropic 平台账户") } +// Scenario: account-owned Composite aliases are scheduled only to accounts that declare the exact mapping. +func TestGatewayService_SelectAccountForModelWithExclusions_CompositeAliasRequiresOwningAccount(t *testing.T) { + groupID := int64(77) + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Priority: 1, + Status: StatusActive, + Schedulable: true, + AccountGroups: []AccountGroup{{GroupID: groupID}}, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Priority: 2, + Status: StatusActive, + Schedulable: true, + Credentials: map[string]any{ + "model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"}, + }, + AccountGroups: []AccountGroup{{GroupID: groupID}}, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + group := &Group{ID: groupID, Platform: PlatformComposite, Status: StatusActive, Hydrated: true} + svc := &GatewayService{ + accountRepo: repo, + groupRepo: &mockGroupRepoForGateway{groups: map[int64]*Group{groupID: group}}, + cfg: testConfig(), + } + ctx := WithCompositeRouteDecision(context.Background(), CompositeRouteDecision{ + Matched: true, + Source: CompositeRouteSourceAccount, + GroupID: groupID, + PublicModel: "reasoning-alias", + TargetPlatform: PlatformAnthropic, + UpstreamModel: "reasoning-alias", + Endpoint: CompositeRouteEndpointResponses, + }) + + account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "reasoning-alias", nil) + require.NoError(t, err) + require.NotNil(t, account) + require.Equal(t, int64(2), account.ID) +} + // TestGatewayService_SelectAccountForModelWithPlatform_Antigravity 测试 antigravity 单平台选择 func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index be63f804f5..977a28a99d 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -2532,6 +2532,11 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string { // isModelSupportedByAccountWithContext 根据账户平台检查模型支持(带 context) // 对于 Antigravity 平台,会先获取映射后的最终模型名(包括 thinking 后缀)再检查支持 func (s *GatewayService) isModelSupportedByAccountWithContext(ctx context.Context, account *Account, requestedModel string) bool { + if source, ok := CompositeRouteSourceFromContext(ctx); ok && source == CompositeRouteSourceAccount { + if publicModel, modelOK := RequestedPublicModelFromContext(ctx); modelOK && !explicitModelMappingClaims(*account, publicModel) { + return false + } + } if account.Platform == PlatformAntigravity { if strings.TrimSpace(requestedModel) == "" { return true diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index f0327eafa6..a4dfdb8762 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -561,7 +561,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) + return buildCodexModelsManifest(modelIDs, nil, nil) } // BuildCodexModelsManifestForGroup derives input capabilities from the @@ -598,6 +598,13 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup( } } imageInputModels := make(map[string]bool, len(modelIDs)) + metadataModels := codexCatalogMetadataModels( + effectivePlatform, + modelIDs, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) for _, modelID := range modelIDs { if groupCodexModelSupportsImageInput( effectivePlatform, @@ -609,10 +616,10 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup( imageInputModels[strings.TrimSpace(modelID)] = true } } - return buildCodexModelsManifest(modelIDs, imageInputModels) + return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels) } -func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]bool) ([]byte, error) { +func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]bool, metadataModels map[string]string) ([]byte, error) { seen := make(map[string]struct{}, len(modelIDs)) models := make([]configuredCodexModelDescriptor, 0, len(modelIDs)) for _, modelID := range modelIDs { @@ -623,11 +630,16 @@ func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]boo if _, exists := seen[modelID]; exists { continue } - if isCodexDedicatedMediaModel(modelID) { + metadataModelID := strings.TrimSpace(metadataModels[modelID]) + if metadataModelID == "" { + metadataModelID = modelID + } + if isCodexDedicatedMediaModel(modelID) || isCodexDedicatedMediaModel(metadataModelID) { continue } seen[modelID] = struct{}{} - descriptor := newConfiguredCodexModelDescriptor(modelID) + descriptor := newConfiguredCodexModelDescriptor(metadataModelID) + descriptor.Slug = modelID if imageInputModels[modelID] { descriptor.InputModalities = []string{"text", "image"} } @@ -638,6 +650,105 @@ func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]boo }{Models: models}) } +func codexCatalogMetadataModels( + platform string, + modelIDs []string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) map[string]string { + metadataModels := make(map[string]string, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + metadataModelID := resolveCodexCatalogMetadataModel( + platform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if metadataModelID != "" && metadataModelID != modelID { + metadataModels[modelID] = metadataModelID + } + } + return metadataModels +} + +func resolveCodexCatalogMetadataModel( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) string { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return "" + } + if platform == PlatformComposite { + if !compositeRoutesAvailable { + return modelID + } + if route, matched := matchCompositeRoute(compositeRoutes, modelID, CompositeRouteEndpointResponses); matched { + if upstreamModel := strings.TrimSpace(route.UpstreamModel); upstreamModel != "" { + return upstreamModel + } + return modelID + } + if codexCompositeRouteMatchesModel(compositeRoutes, modelID) { + return modelID + } + + claimedPlatforms := make(map[string]struct{}) + for _, account := range accounts { + accountPlatform := strings.TrimSpace(account.Platform) + if !isConcreteRequestPlatform(accountPlatform) || !codexExplicitModelMappingClaims(account, modelID) { + continue + } + claimedPlatforms[accountPlatform] = struct{}{} + } + if len(claimedPlatforms) > 1 { + return modelID + } + for accountPlatform := range claimedPlatforms { + return uniqueCodexMappedModel(accounts, accountPlatform, modelID) + } + + detectedPlatform, detected := DetectModelPlatform(modelID) + if !detected { + return modelID + } + platform = detectedPlatform + } + return uniqueCodexMappedModel(accounts, platform, modelID) +} + +func uniqueCodexMappedModel(accounts []Account, platform string, modelID string) string { + targets := make(map[string]struct{}) + for i := range accounts { + account := &accounts[i] + if account.Platform != platform { + continue + } + mappedModel, matched := account.ResolveMappedModel(modelID) + mappedModel = strings.TrimSpace(mappedModel) + if !matched || mappedModel == "" { + continue + } + targets[mappedModel] = struct{}{} + } + if len(targets) != 1 { + return modelID + } + for target := range targets { + return target + } + return modelID +} + func groupCodexModelSupportsImageInput( platform string, modelID string, diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 9581eecf87..a4340062d5 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -324,6 +324,8 @@ func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInp models := decodeCodexManifestModels(t, body) require.Len(t, models, 1) require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.Equal(t, "Grok 4.5", models[0]["display_name"]) + require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) } func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageInput(t *testing.T) { @@ -551,6 +553,106 @@ func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedMod require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) } +// Scenario: a Composite exact alias inherits metadata from its unique mapped target model. +func TestBuildCodexModelsManifestForGroupUsesMappedTargetMetadataForCompositeAlias(t *testing.T) { + t.Parallel() + + const groupID int64 = 733 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 23, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"}, + }, + }}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"reasoning-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "reasoning-alias", models[0]["slug"]) + require.Equal(t, "Claude Opus 4.8", models[0]["display_name"]) + require.Equal(t, "Claude coding and reasoning model routed through Sub2API.", models[0]["description"]) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromManifestModel(t, models[0])) +} + +// Scenario: conflicting targets on the same platform keep the public alias but do not guess capabilities. +func TestBuildCodexModelsManifestForGroupUsesSafeFallbackForConflictingAliasTargets(t *testing.T) { + t.Parallel() + + const groupID int64 = 734 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + { + ID: 24, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"shared-alias": "claude-opus-4-8"}, + }, + }, + { + ID: 25, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"shared-alias": "claude-haiku-4-5-20251001"}, + }, + }, + }, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"shared-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "shared-alias", models[0]["slug"]) + require.Equal(t, "shared-alias", models[0]["display_name"]) + require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"]) + require.Empty(t, effortsFromManifestModel(t, models[0])) +} + +// Scenario: a media-only target remains hidden even when exposed through an ordinary alias. +func TestBuildCodexModelsManifestForGroupOmitsDedicatedMediaTargetAlias(t *testing.T) { + t.Parallel() + + const groupID int64 = 735 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 26, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"creative-alias": "gpt-image-2"}, + }, + }}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"creative-alias"}, + ) + require.NoError(t, err) + require.Empty(t, decodeCodexManifestModels(t, body)) +} + func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) { t.Parallel() diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 9646667bbf..094eff52b9 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -627,6 +627,29 @@ function downloadCodexModelManifest() { ) } +const codexCatalogModelSlugs = computed(() => { + if (!codexModelManifestContent.value) return [] + try { + const payload: unknown = JSON.parse(codexModelManifestContent.value) + if (typeof payload !== 'object' || payload === null || !('models' in payload)) return [] + const models = (payload as { models?: unknown }).models + if (!Array.isArray(models)) return [] + return models.flatMap((model) => { + if (typeof model !== 'object' || model === null || !('slug' in model)) return [] + const slug = (model as { slug?: unknown }).slug + if (typeof slug !== 'string' || !slug.trim()) return [] + return [slug.trim()] + }) + } catch { + return [] + } +}) + +function selectCodexCatalogModel(preferredModel: string): string { + if (codexCatalogModelSlugs.value.includes(preferredModel)) return preferredModel + return codexCatalogModelSlugs.value[0] || preferredModel +} + const escapeHtml = (value: string) => value .replace(/&/g, '&') .replace(/ { const manifest = { models: [ { - slug: 'gpt-5.5', + slug: 'claude-opus-4-8', default_reasoning_level: 'medium', - supported_reasoning_levels: [{ effort: 'xhigh', description: 'Extra-high reasoning depth' }], - input_modalities: ['text', 'image'], + supported_reasoning_levels: [{ effort: 'max', description: 'Maximum reasoning depth' }], + input_modalities: ['text'], model_messages: { instructions_template: 'Use the routed model.' } }, { @@ -684,6 +684,13 @@ describe('UseKeyModal', () => { expect(wrapper.get('[data-testid="codex-model-catalog"]').text()) .toContain('keys.useKeyModal.codexModelCatalog.download') + const loadedUnixConfig = wrapper.findAll('pre code') + .map((code) => code.text()) + .find((content) => content.includes('[model_providers.sub2api]')) + expect(loadedUnixConfig).toContain('model = "claude-opus-4-8"') + expect(loadedUnixConfig).toContain('review_model = "claude-opus-4-8"') + expect(loadedUnixConfig).not.toContain('model = "gpt-5.5"') + const downloadButton = wrapper.findAll('button').find((button) => button.text().includes('keys.useKeyModal.codexModelCatalog.download') ) @@ -705,4 +712,51 @@ describe('UseKeyModal', () => { 'model_catalog_json = "%userprofile%\\\\.codex\\\\codex-models.json"' ) }) + + // Scenario: the platform-preferred model remains selected when the downloaded catalog contains it. + it('keeps the preferred Composite default when it exists in the catalog', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { slug: 'claude-opus-4-8' }, + { slug: 'gpt-5.5' } + ] + }) + })) + + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: 'sk-composite-test', + baseUrl: 'https://example.com/v1', + platform: 'composite' + }, + global: { + stubs: { + BaseDialog: { + template: '