mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user