From b16ed03cad99ec1455f4982659f1c8b53c79a904 Mon Sep 17 00:00:00 2001 From: Long Li Date: Fri, 21 Aug 2026 09:31:56 +0900 Subject: [PATCH] fix(codex): align routed catalogs with actual routes Anthropic fallbacks previously reused Antigravity defaults, leaking Gemini models into Claude-only groups. Composite aliases were also emitted with generic metadata and could be scheduled to accounts that did not own the exact mapping, while generated configs could default to a model absent from the downloaded catalog. Keep provider defaults separated, derive alias capabilities from unique upstream targets with conservative fallback, require exact mapping ownership during scheduling, filter media targets, and choose generated config defaults from the fetched catalog. --- backend/internal/handler/gateway_handler.go | 17 +-- .../internal/handler/gateway_models_test.go | 66 ++++++++++ .../service/gateway_multiplatform_test.go | 55 ++++++++ .../internal/service/gateway_scheduling.go | 5 + .../service/openai_codex_models_service.go | 121 +++++++++++++++++- .../openai_codex_models_service_test.go | 102 +++++++++++++++ frontend/src/components/keys/UseKeyModal.vue | 42 +++++- .../keys/__tests__/UseKeyModal.spec.ts | 60 ++++++++- 8 files changed, 445 insertions(+), 23 deletions(-) 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: '
' + }, + Icon: { + template: '' + } + } + } + }) + + const codexTab = wrapper.findAll('button').find((button) => + button.text().includes('keys.useKeyModal.cliTabs.codexCli') + ) + expect(codexTab).toBeDefined() + await codexTab!.trigger('click') + await wrapper.get('[data-testid="codex-model-catalog-fetch"]').trigger('click') + await flushPromises() + + const config = wrapper.findAll('pre code') + .map((code) => code.text()) + .find((content) => content.includes('[model_providers.sub2api]')) + expect(config).toContain('model = "gpt-5.5"') + expect(config).toContain('review_model = "gpt-5.5"') + }) })