From 2a3dcb499f0e86ec3b495b17fec01e350dab6361 Mon Sep 17 00:00:00 2001 From: li Date: Tue, 7 Jul 2026 14:06:21 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(scheduler):=20=E7=A9=BA=20model=5Fmappi?= =?UTF-8?q?ng=20=E7=9A=84=20OpenAI=20OAuth=20=E8=B4=A6=E5=8F=B7=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=90=B8=E6=94=B6=E5=85=A8=E9=83=A8=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空映射此前一律视为「允许所有」,导致混合部署下请求非 OpenAI 模型 别名(deepseek-v4 等)时,OAuth 账号可能被调度选中;转发阶段对未知 模型原样透传,Codex 上游返回不可重试的 400,请求无法 failover 到 真正支持该模型的 API Key 账号。 IsModelSupported 对空映射的 OpenAI OAuth(未开透传)账号改为与转发 归一化对齐的判定 isOpenAIOAuthServableModel:可归一到已知 Codex 模型 集合(含 gpt-image-*、推理后缀、别名拼写)或 claude-* dispatch 系列 才视为可服务。API Key/显式映射/透传/其他平台语义不变。 Fixes #3662 --- backend/internal/service/account.go | 11 +- .../internal/service/openai_model_mapping.go | 28 +++++ .../openai_oauth_model_support_test.go | 103 ++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/openai_oauth_model_support_test.go diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ae25bf387d..bed15d9c8d 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -750,10 +750,19 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st } // IsModelSupported 检查模型是否在 model_mapping 中(支持通配符) -// 如果未配置 mapping,返回 true(允许所有模型) +// 如果未配置 mapping,返回 true(允许所有模型)。 +// +// 例外:OpenAI OAuth 账号(Codex 上游)的空映射不再视为「允许所有」—— +// 转发阶段 normalizeOpenAIModelForUpstream 会把未知模型原样透传,Codex 上游 +// 对 deepseek-*/glm-* 等第三方模型别名必然返回不可重试的 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) { diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 7cec521221..196ecbc86d 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -20,6 +20,34 @@ func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedMo return mappedModel } +// isOpenAIOAuthServableModel 判断「空 model_mapping 的 OpenAI OAuth 账号」能否 +// 服务请求模型。与转发阶段 normalizeOpenAIModelForUpstream → normalizeCodexModel +// 的行为对齐:只有会被归一到已知 Codex 模型集合的请求(含 gpt-image-* 与 +// 推理后缀变体),或 /v1/messages 调度下有默认映射兜底的 claude-* 系列, +// 才视为可服务。其余模型(deepseek-*/glm-* 等第三方别名)原样透传必然被 +// Codex 上游以不可重试的 400 拒绝,应在调度阶段就跳过该账号(#3662)。 +func isOpenAIOAuthServableModel(requestedModel string) bool { + model := strings.TrimSpace(requestedModel) + if model == "" { + return true // 空模型交由上层必填校验处理 + } + // /v1/messages 调度:claude-* 系列由分组/全局默认映射兜底,见 resolveOpenAIForwardModel。 + if claudeMessagesDispatchFamily(model) != "" { + return true + } + if _, ok := normalizeKnownCodexModel(model); ok { + return true + } + // 兜底剥离 -low/-high 等推理后缀(gpt-5.4-high → gpt-5.4)后再试一次, + // 与 /v1/messages 入站的 routingModel 归一化保持一致。 + if normalized := NormalizeOpenAICompatRequestedModel(model); normalized != model { + if _, ok := normalizeKnownCodexModel(normalized); ok { + return true + } + } + return false +} + // 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. diff --git a/backend/internal/service/openai_oauth_model_support_test.go b/backend/internal/service/openai_oauth_model_support_test.go new file mode 100644 index 0000000000..550eb298c7 --- /dev/null +++ b/backend/internal/service/openai_oauth_model_support_test.go @@ -0,0 +1,103 @@ +//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.3-codex-xhigh", + "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", + } + 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", + "gemini-3.0-pro", + "grok-4", + "qwen3-max", + } + 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.False(t, isOpenAIOAuthServableModel("claude-unknown-family")) // 无 opus/sonnet/haiku 关键字,无默认映射兜底 + require.False(t, isOpenAIOAuthServableModel("deepseek-v4")) +} From 9b75c7b76c7d384662360e3fd80a2bd853fc956c Mon Sep 17 00:00:00 2001 From: li Date: Tue, 7 Jul 2026 14:19:58 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(scheduler):=20=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=BC=82=E6=97=8F=E5=8E=82=E5=95=86=E5=89=8D=E7=BC=80=E9=BB=91?= =?UTF-8?q?=E5=90=8D=E5=8D=95=EF=BC=8C=E5=85=BC=E5=AE=B9=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=BA=A7=E6=A8=A1=E5=9E=8B=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自审发现白名单守卫有误伤面:渠道级模型映射在账号选定之后才改写 请求体模型名,调度过滤看到的是改写前的原始别名。若按 Codex 白名单 判定,「渠道映射自定义别名 → gpt-5.x + 空映射 OAuth 账号」的既有 部署会被误判为 model_not_found。 改为保守 fail-open 黑名单:空映射默认仍允许,仅排除 deepseek/glm/ kimi/qwen/gemini/grok 等明确异族厂商前缀(Codex 上游绝无可能服务, 透传必然换来不可重试 400)。前缀分类先例见 ResolveThinkingProtocol。 --- backend/internal/service/account.go | 10 ++-- .../internal/service/openai_model_mapping.go | 60 +++++++++++++------ .../openai_oauth_model_support_test.go | 18 ++++-- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index bed15d9c8d..d099f93979 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -752,11 +752,11 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st // IsModelSupported 检查模型是否在 model_mapping 中(支持通配符) // 如果未配置 mapping,返回 true(允许所有模型)。 // -// 例外:OpenAI OAuth 账号(Codex 上游)的空映射不再视为「允许所有」—— -// 转发阶段 normalizeOpenAIModelForUpstream 会把未知模型原样透传,Codex 上游 -// 对 deepseek-*/glm-* 等第三方模型别名必然返回不可重试的 400,导致请求 -// 卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。 -// 此处的可服务判定与转发阶段的归一化行为对齐,见 isOpenAIOAuthServableModel。 +// 例外: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 { diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 196ecbc86d..32c4ec55f6 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -20,32 +20,54 @@ 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", + "qwq-", + "minimax", + "gemini-", + "gemma-", + "grok-", + "doubao-", + "hunyuan-", + "llama", + "meta-llama", + "mistral", + "mixtral", + "baichuan", + "ernie-", + "step-", + "seed-", + "yi-", +} + // isOpenAIOAuthServableModel 判断「空 model_mapping 的 OpenAI OAuth 账号」能否 -// 服务请求模型。与转发阶段 normalizeOpenAIModelForUpstream → normalizeCodexModel -// 的行为对齐:只有会被归一到已知 Codex 模型集合的请求(含 gpt-image-* 与 -// 推理后缀变体),或 /v1/messages 调度下有默认映射兜底的 claude-* 系列, -// 才视为可服务。其余模型(deepseek-*/glm-* 等第三方别名)原样透传必然被 -// Codex 上游以不可重试的 400 拒绝,应在调度阶段就跳过该账号(#3662)。 +// 服务请求模型。空映射默认仍是「允许」,仅排除明确属于其他厂商家族的模型 +// (deepseek-*/glm-* 等)——这类请求原样透传必然被 Codex 上游以不可重试的 +// 400 拒绝,且不触发 failover,应在调度阶段就跳过该账号,把请求让给 +// 显式声明支持该模型的账号(#3662)。 func isOpenAIOAuthServableModel(requestedModel string) bool { - model := strings.TrimSpace(requestedModel) + model := strings.ToLower(lastOpenAIModelSegment(requestedModel)) if model == "" { return true // 空模型交由上层必填校验处理 } - // /v1/messages 调度:claude-* 系列由分组/全局默认映射兜底,见 resolveOpenAIForwardModel。 - if claudeMessagesDispatchFamily(model) != "" { - return true - } - if _, ok := normalizeKnownCodexModel(model); ok { - return true - } - // 兜底剥离 -low/-high 等推理后缀(gpt-5.4-high → gpt-5.4)后再试一次, - // 与 /v1/messages 入站的 routingModel 归一化保持一致。 - if normalized := NormalizeOpenAICompatRequestedModel(model); normalized != model { - if _, ok := normalizeKnownCodexModel(normalized); ok { - return true + for _, prefix := range openAIOAuthForeignModelPrefixes { + if strings.HasPrefix(model, prefix) { + return false } } - return false + return true } // resolveOpenAICompactForwardModel determines the compact-only upstream model diff --git a/backend/internal/service/openai_oauth_model_support_test.go b/backend/internal/service/openai_oauth_model_support_test.go index 550eb298c7..b08c42a7ea 100644 --- a/backend/internal/service/openai_oauth_model_support_test.go +++ b/backend/internal/service/openai_oauth_model_support_test.go @@ -24,14 +24,15 @@ func TestIsModelSupported_OpenAIOAuthEmptyMapping_ServableModels(t *testing.T) { "gpt-5.4", "gpt-5.4-high", // 推理后缀变体 "gpt-5.3-codex", - "gpt-5.3-codex-xhigh", "gpt-5.1-codex-mini", "gpt-5", "codex-mini-latest", - "gpt5.3codexspark", // 别名拼写归一化 + "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) @@ -41,16 +42,20 @@ func TestIsModelSupported_OpenAIOAuthEmptyMapping_ServableModels(t *testing.T) { func TestIsModelSupported_OpenAIOAuthEmptyMapping_RejectsForeignModels(t *testing.T) { account := newOpenAIOAuthAccountForModelTest() - // Codex 上游必然以不可重试的 400 拒绝这些模型;调度阶段就应跳过该账号, - // 让显式声明支持的 API Key 账号接手(#3662)。 + // 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) @@ -98,6 +103,7 @@ 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.False(t, isOpenAIOAuthServableModel("claude-unknown-family")) // 无 opus/sonnet/haiku 关键字,无默认映射兜底 - require.False(t, isOpenAIOAuthServableModel("deepseek-v4")) + require.True(t, isOpenAIOAuthServableModel("DeepThink-x")) // 非黑名单前缀,保持允许 + require.False(t, isOpenAIOAuthServableModel("DeepSeek-V4")) // 大小写不敏感 + require.False(t, isOpenAIOAuthServableModel("qwen3-235b-thinking")) } From fa01aec80d101fa3a28639b1b6324e6fdfc1d777 Mon Sep 17 00:00:00 2001 From: li Date: Tue, 7 Jul 2026 15:00:00 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E5=89=8D=E7=BC=80=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E5=8A=A0=E8=BF=9E=E5=AD=97=E7=AC=A6=EF=BC=8C=E4=B8=8E=20thinki?= =?UTF-8?q?ng=5Fprotocol.go=20=E9=A3=8E=E6=A0=BC=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deepseek → deepseek-、moonshot → moonshot-、qwen → qwen-/qwen2-/qwen3-/qwen4-、 minimax → minimax-、llama → llama-/llama2-/llama3-、mistral → mistral-、 mixtral → mixtral-。避免无连字符前缀误伤假想的连写模型名。 --- .../internal/service/openai_model_mapping.go | 21 ++++++++++++------- .../openai_oauth_model_support_test.go | 1 + 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 32c4ec55f6..cb7a8ca84b 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -29,23 +29,28 @@ func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedMo // (调度过滤看到的是改写前的原始模型名)。前缀分类的先例见 // ResolveThinkingProtocol(thinking_protocol.go)。 var openAIOAuthForeignModelPrefixes = []string{ - "deepseek", + "deepseek-", "glm-", "kimi-", - "moonshot", - "qwen", + "moonshot-", + "qwen-", + "qwen2-", + "qwen3-", + "qwen4-", "qwq-", - "minimax", + "minimax-", "gemini-", "gemma-", "grok-", "doubao-", "hunyuan-", - "llama", + "llama-", + "llama2-", + "llama3-", "meta-llama", - "mistral", - "mixtral", - "baichuan", + "mistral-", + "mixtral-", + "baichuan-", "ernie-", "step-", "seed-", diff --git a/backend/internal/service/openai_oauth_model_support_test.go b/backend/internal/service/openai_oauth_model_support_test.go index b08c42a7ea..1350dcd3bf 100644 --- a/backend/internal/service/openai_oauth_model_support_test.go +++ b/backend/internal/service/openai_oauth_model_support_test.go @@ -106,4 +106,5 @@ func TestIsOpenAIOAuthServableModel(t *testing.T) { require.True(t, isOpenAIOAuthServableModel("DeepThink-x")) // 非黑名单前缀,保持允许 require.False(t, isOpenAIOAuthServableModel("DeepSeek-V4")) // 大小写不敏感 require.False(t, isOpenAIOAuthServableModel("qwen3-235b-thinking")) + require.True(t, isOpenAIOAuthServableModel("deepseekcoder")) // 无连字符 → 非黑名单前缀,保持允许 }