Merge pull request #3781 from fengshao1227/fix/openai-oauth-empty-mapping-model-guard

fix(scheduler): 空 model_mapping 的 OpenAI OAuth 账号不再吸收全部模型
This commit is contained in:
Wesley Liddick
2026-07-09 14:37:33 +08:00
committed by GitHub
3 changed files with 175 additions and 1 deletions
+10 -1
View File
@@ -750,10 +750,19 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
}
// IsModelSupported 检查模型是否在 model_mapping 中(支持通配符)
// 如果未配置 mapping,返回 true(允许所有模型)
// 如果未配置 mapping,返回 true(允许所有模型)。
//
// 例外:OpenAI OAuth 账号(Codex 上游)的空映射会排除明确属于其他厂商
// 家族的模型(deepseek-*/glm-* 等)——转发阶段 normalizeOpenAIModelForUpstream
// 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致
// 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
// 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
func (a *Account) IsModelSupported(requestedModel string) bool {
mapping := a.GetModelMapping()
if len(mapping) == 0 {
if a.IsOpenAIOAuth() && !a.IsOpenAIPassthroughEnabled() {
return isOpenAIOAuthServableModel(requestedModel)
}
return true // 无映射 = 允许所有
}
if mappingSupportsRequestedModel(mapping, requestedModel) {
@@ -20,6 +20,61 @@ func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedMo
return mappedModel
}
// openAIOAuthForeignModelPrefixes 列出明确属于其他厂商家族的模型名前缀。
// Codex 上游不可能服务这些模型:转发阶段 normalizeOpenAIModelForUpstream
// 对未知模型原样透传,上游必然返回不可重试的 400。
//
// 采用保守黑名单而非 Codex 模型白名单:未知/自定义别名保持「允许」,
// 以兼容渠道级模型映射等「账号选定之后才改写模型名」的部署方式
// (调度过滤看到的是改写前的原始模型名)。前缀分类的先例见
// ResolveThinkingProtocol(thinking_protocol.go)。
var openAIOAuthForeignModelPrefixes = []string{
"deepseek-",
"glm-",
"kimi-",
"moonshot-",
"qwen-",
"qwen2-",
"qwen3-",
"qwen4-",
"qwq-",
"minimax-",
"gemini-",
"gemma-",
"grok-",
"doubao-",
"hunyuan-",
"llama-",
"llama2-",
"llama3-",
"meta-llama",
"mistral-",
"mixtral-",
"baichuan-",
"ernie-",
"step-",
"seed-",
"yi-",
}
// isOpenAIOAuthServableModel 判断「空 model_mapping 的 OpenAI OAuth 账号」能否
// 服务请求模型。空映射默认仍是「允许」,仅排除明确属于其他厂商家族的模型
// (deepseek-*/glm-* 等)——这类请求原样透传必然被 Codex 上游以不可重试的
// 400 拒绝,且不触发 failover,应在调度阶段就跳过该账号,把请求让给
// 显式声明支持该模型的账号(#3662)。
func isOpenAIOAuthServableModel(requestedModel string) bool {
model := strings.ToLower(lastOpenAIModelSegment(requestedModel))
if model == "" {
return true // 空模型交由上层必填校验处理
}
for _, prefix := range openAIOAuthForeignModelPrefixes {
if strings.HasPrefix(model, prefix) {
return false
}
}
return true
}
// resolveOpenAICompactForwardModel determines the compact-only upstream model
// for /responses/compact requests. It never affects normal /responses traffic.
// When no compact-specific mapping matches, the input model is returned as-is.
@@ -0,0 +1,110 @@
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func newOpenAIOAuthAccountForModelTest() *Account {
return &Account{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
}
}
func TestIsModelSupported_OpenAIOAuthEmptyMapping_ServableModels(t *testing.T) {
account := newOpenAIOAuthAccountForModelTest()
servable := []string{
"", // 空模型交由上层必填校验
"gpt-5.4",
"gpt-5.4-high", // 推理后缀变体
"gpt-5.3-codex",
"gpt-5.1-codex-mini",
"gpt-5",
"codex-mini-latest",
"gpt5.3codexspark", // 别名拼写
"gpt-image-1", // 图像生成模型
"claude-sonnet-4-6", // /v1/messages 调度默认映射兜底
"claude-3-opus-20240229",
"gpt-4o", // 保守 fail-open:非黑名单模型保持允许
"my-custom-alias", // 自定义别名可能由渠道级映射在转发前改写,保持允许
}
for _, model := range servable {
require.True(t, account.IsModelSupported(model), "expected %q to be servable by empty-mapping OpenAI OAuth account", model)
}
}
func TestIsModelSupported_OpenAIOAuthEmptyMapping_RejectsForeignModels(t *testing.T) {
account := newOpenAIOAuthAccountForModelTest()
// Codex 上游必然以不可重试的 400 拒绝这些厂商家族;调度阶段就应跳过
// 该账号,让显式声明支持的 API Key 账号接手(#3662)。
foreign := []string{
"deepseek-v4",
"deepseek-chat",
"glm-4.7",
"kimi-k2",
"moonshot-v1-128k",
"gemini-3.0-pro",
"grok-4",
"qwen3-max",
"minimax-m2.5",
"llama-3.3-70b",
"provider/deepseek-v4", // vendor/model 形式取最后一段判定
}
for _, model := range foreign {
require.False(t, account.IsModelSupported(model), "expected %q to be rejected by empty-mapping OpenAI OAuth account", model)
}
}
func TestIsModelSupported_OpenAIOAuthExplicitMappingUnchanged(t *testing.T) {
account := newOpenAIOAuthAccountForModelTest()
account.Credentials = map[string]any{
"model_mapping": map[string]any{"deepseek-v4": "gpt-5.4"},
}
// 显式映射沿用原有语义:命中映射即支持,未命中即不支持。
require.True(t, account.IsModelSupported("deepseek-v4"))
require.False(t, account.IsModelSupported("glm-4.7"))
}
func TestIsModelSupported_OpenAIOAuthPassthroughAllowsAll(t *testing.T) {
account := newOpenAIOAuthAccountForModelTest()
account.Extra = map[string]any{"openai_passthrough": true}
// 透传模式仅替换认证,模型语义由上游决定,保持"允许所有"。
require.True(t, account.IsModelSupported("deepseek-v4"))
}
func TestIsModelSupported_OpenAIAPIKeyEmptyMappingAllowsAll(t *testing.T) {
account := &Account{
ID: 2,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
}
// API Key 账号(第三方 OpenAI 兼容上游)可服务任意别名,语义不变。
require.True(t, account.IsModelSupported("deepseek-v4"))
require.True(t, account.IsModelSupported("gpt-5.4"))
}
func TestIsModelSupported_NonOpenAIPlatformsUnchanged(t *testing.T) {
anthropic := &Account{ID: 3, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
require.True(t, anthropic.IsModelSupported("claude-sonnet-4-6"))
require.True(t, anthropic.IsModelSupported("deepseek-v4"))
}
func TestIsOpenAIOAuthServableModel(t *testing.T) {
require.True(t, isOpenAIOAuthServableModel("gpt-5.4-high"))
require.True(t, isOpenAIOAuthServableModel(" gpt-5.3-codex "))
require.True(t, isOpenAIOAuthServableModel("claude-3-5-haiku-20241022"))
require.True(t, isOpenAIOAuthServableModel("DeepThink-x")) // 非黑名单前缀,保持允许
require.False(t, isOpenAIOAuthServableModel("DeepSeek-V4")) // 大小写不敏感
require.False(t, isOpenAIOAuthServableModel("qwen3-235b-thinking"))
require.True(t, isOpenAIOAuthServableModel("deepseekcoder")) // 无连字符 → 非黑名单前缀,保持允许
}