diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index ec1bf901ad..6568b3f6b4 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -3515,3 +3515,74 @@ func TestGatewayService_ResolveGatewayGroup_DetectsFallbackCycle(t *testing.T) { require.Nil(t, gotID) require.Contains(t, err.Error(), "fallback group cycle") } + +func TestModelRoutingAppliesToPlatform(t *testing.T) { + for _, tc := range []struct { + name string + targetPlatform string + groupPlatform string + want bool + }{ + {"anthropic group", PlatformAnthropic, PlatformAnthropic, true}, + {"openai group", PlatformOpenAI, PlatformOpenAI, true}, + {"composite group resolved to anthropic", PlatformAnthropic, PlatformComposite, true}, + {"composite group resolved to openai", PlatformOpenAI, PlatformComposite, true}, + {"target platform outside the allowed set", PlatformGemini, PlatformGemini, false}, + {"composite group resolved outside the allowed set", PlatformGemini, PlatformComposite, false}, + {"group platform does not match target", PlatformOpenAI, PlatformAnthropic, false}, + {"empty target platform", "", PlatformOpenAI, false}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, modelRoutingAppliesToPlatform(tc.targetPlatform, tc.groupPlatform)) + }) + } +} + +// Model routing used to be read only for requests resolved to Anthropic, so an +// OpenAI group's rules were stored and displayed but never honored. Routing an +// OpenAI request must pick the routed account instead of the group-wide winner. +func TestGatewayService_SelectAccountForModelWithPlatform_RoutedOpenAIGroup(t *testing.T) { + ctx := context.Background() + groupID := int64(13) + requestedModel := "gpt-6-astra" + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + // Priority 1 would win any group-wide selection, but it is not routed. + {ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true}, + {ID: 2, Platform: PlatformOpenAI, Priority: 5, Status: StatusActive, Schedulable: true}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + groupRepo := &mockGroupRepoForGateway{ + groups: map[int64]*Group{ + groupID: { + ID: groupID, + Name: "openai-route-group", + Platform: PlatformOpenAI, + Status: StatusActive, + Hydrated: true, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{ + requestedModel: {2}, + }, + }, + }, + } + + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: testConfig(), + groupRepo: groupRepo, + } + + acc, err := svc.selectAccountForModelWithPlatform(ctx, &groupID, "", requestedModel, nil, PlatformOpenAI) + require.NoError(t, err) + require.NotNil(t, acc) + require.Equal(t, int64(2), acc.ID, "routed account must win over the higher-priority unrouted one") +} diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index bd05f9e1ca..f5dd160c79 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -216,7 +216,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro return nil, err } preferOAuth := platform == PlatformGemini - if s.debugModelRoutingEnabled() && platform == PlatformAnthropic && requestedModel != "" { + if s.debugModelRoutingEnabled() && requestedModel != "" && modelRoutingAppliesToTargetPlatform(platform) { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] load-aware enabled: group_id=%v model=%s session=%s platform=%s", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), platform) } @@ -250,10 +250,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro return needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel) } - // 获取模型路由配置(anthropic 目标平台;composite 分组按目标平台判断) + // 获取模型路由配置(anthropic / openai 目标平台;composite 分组按目标平台判断) var routingAccountIDs []int64 - if group != nil && requestedModel != "" && platform == PlatformAnthropic && - (group.Platform == PlatformAnthropic || group.Platform == PlatformComposite) { + if group != nil && requestedModel != "" && + modelRoutingAppliesToPlatform(platform, group.Platform) { routingAccountIDs = group.GetRoutingAccountIDs(requestedModel) if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] context group routing: group_id=%d model=%s enabled=%v rules=%d matched_ids=%v session=%s sticky_account=%d", @@ -865,8 +865,35 @@ func (s *GatewayService) ResolveGroupByID(ctx context.Context, groupID int64) (* return s.resolveGroupByID(ctx, groupID) } +// modelRoutingAppliesToPlatform 判定模型路由规则是否适用于本次请求。 +// +// 路由规则的存储与查表(Group.ModelRouting / GetRoutingAccountIDs)本身与平台无关, +// 早期只有 Anthropic 目标平台会读取它。OpenAI 分组同样存在“同一个公开别名由不同账号 +// 映射到不同上游模型”的故障转移写法,需要显式路由来指定谁是主、谁是备,因此这里把 +// Anthropic 与 OpenAI 一并放行。 +// +// targetPlatform 是请求解析后的目标平台;groupPlatform 是分组自身的平台。composite +// 分组不限定自身平台,只要目标平台落在放行集合内即可复用其规则。 +func modelRoutingAppliesToPlatform(targetPlatform, groupPlatform string) bool { + if !modelRoutingAppliesToTargetPlatform(targetPlatform) { + return false + } + return groupPlatform == targetPlatform || groupPlatform == PlatformComposite +} + +// modelRoutingAppliesToTargetPlatform 是放行平台集合的唯一定义处:新增平台只改这里。 +// 在只知道目标平台、还没取到分组的位置(调试日志、取分组前的短路)单独判定用。 +func modelRoutingAppliesToTargetPlatform(targetPlatform string) bool { + switch targetPlatform { + case PlatformAnthropic, PlatformOpenAI: + return true + default: + return false + } +} + func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupID *int64, requestedModel string, platform string) []int64 { - if groupID == nil || requestedModel == "" || platform != PlatformAnthropic { + if groupID == nil || requestedModel == "" || !modelRoutingAppliesToTargetPlatform(platform) { return nil } group, err := s.resolveGroupByID(ctx, *groupID) @@ -876,11 +903,11 @@ func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupI } return nil } - // Model routing applies only to requests resolved to Anthropic. Composite - // groups may still use those rules once their model resolved to Anthropic. - if group.Platform != PlatformAnthropic && group.Platform != PlatformComposite { + // 路由规则适用于解析到 Anthropic 或 OpenAI 的请求;composite 分组在其模型解析到 + // 上述平台后同样可以复用这些规则。 + if !modelRoutingAppliesToPlatform(platform, group.Platform) { if s.debugModelRoutingEnabled() { - logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] skip: non-anthropic group platform: group_id=%d group_platform=%s model=%s", group.ID, group.Platform, requestedModel) + logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] skip: group platform not eligible: group_id=%d group_platform=%s target_platform=%s model=%s", group.ID, group.Platform, platform, requestedModel) } return nil } diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index 656919fd8c..63a819e1e0 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -89,10 +89,23 @@ func accountCodexToolCapabilities(account *Account, modelID string) map[string]j return capabilities } +// codexModelRoutingAccountIDs 返回分组显式为该公开别名声明的账号集合。 +// +// 返回非空表示运营者已经用 model_routing 指明“这个别名由这些账号服务”,此时别名的 +// 归属不再是需要推断的未知量:能力声明只看这些账号,且不再因为它们映射到不同上游而 +// 判定为冲突。返回空表示没有相关规则,保持原有的全量推断与失败即关闭行为。 +func codexModelRoutingAccountIDs(group *Group, modelID string) []int64 { + if group == nil { + return nil + } + return group.GetRoutingAccountIDs(strings.TrimSpace(modelID)) +} + func groupCodexModelMetadata( platform string, modelID string, accounts []Account, + group *Group, compositeRoutes []CompositeModelRoute, compositeRoutesAvailable bool, ) (codexModelMetadataOverride, bool) { @@ -100,6 +113,9 @@ func groupCodexModelMetadata( if modelID == "" { return codexModelMetadataOverride{}, false } + // 显式路由规则本身与平台无关,先取出来供后续两处判定共用。 + routedAccountIDs := codexModelRoutingAccountIDs(group, modelID) + routed := len(routedAccountIDs) > 0 upstreamModel := modelID if platform == PlatformComposite { var resolved bool @@ -110,7 +126,7 @@ func groupCodexModelMetadata( compositeRoutesAvailable, ) if !resolved { - if codexExplicitModelTargetsConflict(accounts, modelID) { + if !routed && codexExplicitModelTargetsConflict(accounts, modelID) { return codexModelMetadataOverride{ reasoningConflict: true, inputModalitiesConflict: true, @@ -125,14 +141,21 @@ func groupCodexModelMetadata( explicitClaims := false if upstreamModel == modelID { - for _, account := range accounts { - if account.Platform == platform && codexExplicitModelMappingClaims(account, modelID) { + for i := range accounts { + account := &accounts[i] + if routed && !containsInt64(routedAccountIDs, account.ID) { + continue + } + if account.Platform == platform && codexExplicitModelMappingClaims(*account, modelID) { explicitClaims = true break } } } - explicitTargetsConflict := explicitClaims && codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID) + // 别名已被显式路由声明时,"多个账号映射到不同上游"是故障转移的正常写法,不是歧义, + // 因此不再失败即关闭;能力回落到与单账号无快照时相同的按名推导。 + explicitTargetsConflict := explicitClaims && !routed && + codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID) publicAlias := upstreamModel != modelID candidates := make([]UpstreamModelMetadata, 0) missingMetadata := false @@ -141,6 +164,9 @@ func groupCodexModelMetadata( if account.Platform != platform { continue } + if routed && !containsInt64(routedAccountIDs, account.ID) { + continue + } var lookupModel string if explicitClaims { if !codexExplicitModelMappingClaims(*account, modelID) { diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index 55bc233ed2..616ff88606 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -30,7 +30,7 @@ func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testi {"implemented chat bridge", []Account{bridge}, true}, } { t.Run(tt.name, func(t *testing.T) { - body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, tt.accounts, nil, true) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, tt.accounts, nil, nil, true) require.NoError(t, err) model := decodeCodexManifestModels(t, body)[0] require.Equal(t, "public-astra", model["slug"]) @@ -48,7 +48,7 @@ func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testi "comp_hash": json.RawMessage(`"3000"`), "tool_mode": json.RawMessage("null"), "use_responses_lite": json.RawMessage("false"), }}, }}) - body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{official, custom}, nil, true) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{official, custom}, nil, nil, true) require.NoError(t, err) model := decodeCodexManifestModels(t, body)[0] require.Equal(t, true, model["supports_search_tool"]) @@ -200,7 +200,7 @@ func TestBuildCodexModelsManifestForGroupAdvertisesSearchOnlyForChatBridgeRoutes t.Run(tc.name, func(t *testing.T) { t.Parallel() body, err := buildCodexModelsManifestForAccounts( - PlatformOpenAI, []string{"company-coding-model"}, tc.accounts, nil, true, + PlatformOpenAI, []string{"company-coding-model"}, tc.accounts, nil, nil, true, ) require.NoError(t, err) models := decodeCodexManifestModels(t, body) @@ -585,7 +585,7 @@ func TestAstraCodexToolCapabilitiesKeepAPIKeyResponsesLiteGuard(t *testing.T) { account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{"use_responses_lite": json.RawMessage("true")}}, }}) - body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"my-astra"}, []Account{account}, nil, true) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"my-astra"}, []Account{account}, nil, nil, true) require.NoError(t, err) require.Equal(t, false, decodeCodexManifestModels(t, body)[0]["use_responses_lite"]) body, err = adjustAPIKeyCodexModelsManifest([]byte(`{"models":[{"slug":"my-astra","use_responses_lite":true}]}`), &account) @@ -593,4 +593,99 @@ func TestAstraCodexToolCapabilitiesKeepAPIKeyResponsesLiteGuard(t *testing.T) { require.Equal(t, false, decodeCodexManifestModels(t, body)[0]["use_responses_lite"]) } +// Scenario: an OpenAI group keeps a failover account whose mapping points the +// shared public alias at a different upstream model. Without an explicit routing +// rule the alias target is ambiguous and capabilities must fail closed; with one, +// the operator has declared who serves the alias, so the reasoning slider and +// image input must survive. +func TestCodexAliasFailoverMappingHonorsModelRouting(t *testing.T) { + newAccount := func(id int64, target string, priority int) Account { + return Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Priority: priority, + Credentials: map[string]any{ + "base_url": "https://relay.example/v1", + "model_mapping": map[string]any{"gpt-6-astra": target}, + }, + } + } + // Account 16-alike serves the alias natively; account 1-alike is the failover + // and can only reach a different upstream model. + primary := newAccount(16, "gpt-6-astra", 0) + failover := newAccount(1, "gpt-5.6-sol", 10) + accounts := []Account{primary, failover} + + // supported_reasoning_levels entries are {effort, description} objects; the + // slider only cares about the effort names. + manifestFieldsOf := func(t *testing.T, group *Group) ([]string, any, any) { + t.Helper() + body, err := buildCodexModelsManifestForAccounts( + PlatformOpenAI, []string{"gpt-6-astra"}, accounts, group, nil, true, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + model := models[0] + require.Equal(t, "gpt-6-astra", model["slug"]) + var efforts []string + levels, _ := model["supported_reasoning_levels"].([]any) + for _, level := range levels { + entry, ok := level.(map[string]any) + require.True(t, ok, "reasoning level entry must be an object") + effort, ok := entry["effort"].(string) + require.True(t, ok, "reasoning level entry must carry an effort name") + efforts = append(efforts, effort) + } + return efforts, model["default_reasoning_level"], model["input_modalities"] + } + + t.Run("no routing rule still fails closed", func(t *testing.T) { + levels, def, modalities := manifestFieldsOf(t, nil) + require.Empty(t, levels, "ambiguous alias target must not advertise reasoning levels") + require.Nil(t, def) + require.Equal(t, []any{"text"}, modalities) + }) + + t.Run("routing rule resolves the alias", func(t *testing.T) { + group := &Group{ + ID: 2, + Platform: PlatformOpenAI, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{"gpt-6-astra": {16, 1}}, + } + levels, def, modalities := manifestFieldsOf(t, group) + require.Equal(t, + []string{"low", "medium", "high", "xhigh", "max"}, + levels, + "routed alias must keep a movable reasoning slider", + ) + require.Equal(t, "medium", def) + require.Contains(t, modalities, "image") + }) + + t.Run("routing disabled falls back to failing closed", func(t *testing.T) { + group := &Group{ + ID: 2, + Platform: PlatformOpenAI, + ModelRoutingEnabled: false, + ModelRouting: map[string][]int64{"gpt-6-astra": {16, 1}}, + } + levels, _, _ := manifestFieldsOf(t, group) + require.Empty(t, levels, "a disabled routing rule must not resolve the alias") + }) + + t.Run("routing rule for another alias does not leak", func(t *testing.T) { + group := &Group{ + ID: 2, + Platform: PlatformOpenAI, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{"gpt-5.6-sol": {1}}, + } + levels, _, _ := manifestFieldsOf(t, group) + require.Empty(t, levels, "unrelated routing rules must not resolve this alias") + }) +} + // Scenario: mixed groups prefer capability metadata synced for the routed account. diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 742be217ec..c7cbc7c0cc 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -137,6 +137,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( PlatformOpenAI, configuredModels, catalog, + group, nil, true, ) @@ -818,6 +819,7 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup( effectivePlatform, modelIDs, catalog, + group, compositeRoutes, compositeRoutesAvailable, ) @@ -827,6 +829,7 @@ func buildCodexModelsManifestForAccounts( effectivePlatform string, modelIDs []string, accounts []Account, + group *Group, compositeRoutes []CompositeModelRoute, compositeRoutesAvailable bool, ) ([]byte, error) { @@ -864,6 +867,7 @@ func buildCodexModelsManifestForAccounts( effectivePlatform, modelID, accounts, + group, compositeRoutes, compositeRoutesAvailable, ); ok {