Merge pull request #6688 from suixincrazy/fix/codex-manifest-failover-alias

fix(openai): honor model_routing for Codex manifests and scheduling
This commit is contained in:
Wesley Liddick
2026-09-07 17:36:26 +08:00
committed by GitHub
5 changed files with 240 additions and 17 deletions
@@ -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")
}
+36 -9
View File
@@ -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
}
@@ -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) {
@@ -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.
@@ -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 {