From a939553c873e4c6ca2ff6c5a5b56e4a37114b1d5 Mon Sep 17 00:00:00 2001 From: alfadb Date: Thu, 10 Sep 2026 22:35:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(deepseek):=20=E6=A8=A1=E5=9E=8B=E5=90=8D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=8D=E5=86=8D=E9=BB=98=E8=AE=A4=E5=85=9C?= =?UTF-8?q?=E5=BA=95=EF=BC=8C=E6=9C=AA=E9=85=8D=E7=BD=AE=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E6=97=B6=E6=8C=89=E5=AE=98=E6=96=B9=E5=90=8D=E5=8D=95=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 未配置 model_mapping 的 DeepSeek 账号不再放行任意模型名,改为官方名单精确匹配 (deepseek-flash / deepseek-v4-pro / deepseek-v4-flash / deepseek-v4-flash-vision-exp / deepseek-v4-pro-0813) - 校验前先归一化:剥离 Claude Code 客户端上下文后缀 [1m](含重复形式)并统一小写, 使官方 Claude Code 接入写法 deepseek-flash[1m] 继续可用 - 未知模型名在调度阶段即拒绝,返回 404 model_not_found,避免透传上游后触发 per-(账号,模型) 冷却、并避免上游把不认识的模型名静默兜底成 deepseek-flash - 出站前剥离泄漏的 [1m] 后缀,仅作用于 DeepSeek 平台 - 配置了 model_mapping 的账号语义不变(映射优先) --- backend/internal/service/account.go | 7 ++ .../internal/service/account_wildcard_test.go | 76 +++++++++++++++++++ .../service/openai_codex_transform.go | 10 ++- .../internal/service/openai_model_mapping.go | 37 +++++++++ .../service/openai_model_mapping_test.go | 24 ++++++ 5 files changed, 153 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index b5268e9102..484e3841b4 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -848,6 +848,10 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st // 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致 // 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。 // 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。 +// +// 例外:DeepSeek 平台的空映射不再是「允许所有」,改按官方模型白名单判定 +// (isDeepseekServableModel)——未知模型名透传上游只会得到 404/400,并误触发 +// per-(账号,模型) 30 分钟冷却;带 [1m] 上下文后缀的写法先归一化再比对。 func (a *Account) IsModelSupported(requestedModel string) bool { // 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。 // 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后, @@ -861,6 +865,9 @@ func (a *Account) IsModelSupported(requestedModel string) bool { if a.IsOpenAIOAuth() { return isOpenAIOAuthServableModel(requestedModel) } + if a.Platform == PlatformDeepseek { + return isDeepseekServableModel(requestedModel) + } return true // 无映射 = 允许所有 } if mappingSupportsRequestedModel(mapping, requestedModel) { diff --git a/backend/internal/service/account_wildcard_test.go b/backend/internal/service/account_wildcard_test.go index 3843237e10..c2f08d94a4 100644 --- a/backend/internal/service/account_wildcard_test.go +++ b/backend/internal/service/account_wildcard_test.go @@ -231,6 +231,82 @@ func TestAccountIsModelSupported(t *testing.T) { requestedModel: "gemini-3-flash", expected: false, }, + + // DeepSeek 空映射:官方模型白名单(不再是「允许所有」) + { + name: "deepseek empty mapping allows official flash", + platform: PlatformDeepseek, + credentials: map[string]any{}, + requestedModel: "deepseek-flash", + expected: true, + }, + { + name: "deepseek empty mapping normalizes claude code long context suffix", + platform: PlatformDeepseek, + credentials: map[string]any{}, + requestedModel: "deepseek-flash[1m]", + expected: true, + }, + { + name: "deepseek empty mapping matches case-insensitively", + platform: PlatformDeepseek, + credentials: map[string]any{}, + requestedModel: "deepseek-FLASH", + expected: true, + }, + { + name: "deepseek empty mapping allows versioned pro name", + platform: PlatformDeepseek, + credentials: map[string]any{}, + requestedModel: "deepseek-v4-pro-0813", + expected: true, + }, + { + name: "deepseek empty mapping rejects retired chat model", + platform: PlatformDeepseek, + credentials: map[string]any{}, + requestedModel: "deepseek-chat", + expected: false, + }, + { + name: "deepseek empty mapping rejects foreign claude model", + platform: PlatformDeepseek, + credentials: map[string]any{}, + requestedModel: "claude-sonnet-4-6", + expected: false, + }, + { + name: "deepseek empty mapping rejects unknown gpt model", + platform: PlatformDeepseek, + credentials: map[string]any{}, + requestedModel: "gpt-future-model", + expected: false, + }, + { + name: "deepseek empty mapping rejects typo", + platform: PlatformDeepseek, + credentials: map[string]any{}, + requestedModel: "deepseek-flas", + expected: false, + }, + { + name: "deepseek explicit mapping wins over whitelist", + platform: PlatformDeepseek, + credentials: map[string]any{ + "model_mapping": map[string]any{ + "foo-bar": "deepseek-flash", + }, + }, + requestedModel: "foo-bar", + expected: true, + }, + { + name: "non deepseek empty mapping still allows all", + platform: PlatformAnthropic, + credentials: map[string]any{}, + requestedModel: "any-model", + expected: true, + }, } for _, tt := range tests { diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index d5bae90e6d..8feac0843f 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -1228,7 +1228,15 @@ func normalizeOpenAIModelForUpstream(account *Account, model string) string { if account == nil || account.UsesOpenAICodexProtocol() { return normalizeCodexModel(model) } - return strings.TrimSpace(model) + model = strings.TrimSpace(model) + if account.Platform == PlatformDeepseek { + // DeepSeek 走 OpenAI 兼容入口,不经 Anthropic 入站的 [1m] 后缀归一 + // (parseGatewayRequestCurrentBody 仅处理 PlatformAnthropic 协议)。 + // 官方 Claude Code 接入要求 ANTHROPIC_MODEL=deepseek-flash[1m] 写法, + // 出站前剥离泄漏的客户端上下文后缀,转发规范名给上游。 + return normalizeClaudeCodeLongContextModel(model) + } + return model } func SupportsVerbosity(model string) bool { diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 0eb0672f8d..7fd67468b1 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -82,6 +82,43 @@ func isOpenAIOAuthServableModel(requestedModel string) bool { return true } +// deepseekServableModels 列出 DeepSeek 平台账号在「未配置 model_mapping」时 +// 可服务的官方模型名(精确匹配,小写比较)。deepseek-flash 与 deepseek-v4-pro +// 为官方现行名;deepseek-v4-flash / deepseek-v4-flash-vision-exp 为处于兼容 +// 路由期的旧名(上游仍接受并按 flash 价计费);deepseek-v4-pro-0813 为与 +// 计费 pro 档口径一致的版本化名。 +var deepseekServableModels = []string{ + "deepseek-flash", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + "deepseek-v4-pro-0813", +} + +// isDeepseekServableModel 报告 DeepSeek 平台账号在「未配置 model_mapping」时 +// 可服务的模型名。官方现行模型为 deepseek-flash 与 deepseek-v4-pro;旧名 +// deepseek-v4-flash / deepseek-v4-flash-vision-exp 处于兼容路由期(上游仍接受并 +// 按 flash 价计费),deepseek-v4-pro-0813 为版本化名。其余一律拒绝:既避免把 +// 未知模型透传给上游(上游 404/400 会触发 per-model 冷却并掩盖真实配置错误), +// 也避免上游把不认识的模型名静默兜底成 deepseek-flash。 +// Source: https://api-docs.deepseek.com/quick_start/pricing +// +// https://api-docs.deepseek.com/guides/anthropic_api +func isDeepseekServableModel(requestedModel string) bool { + // 官方 Claude Code 接入文档要求 ANTHROPIC_MODEL=deepseek-flash[1m]: + // [1m] 是客户端上下文选择器,先按同一规则归一化再比对白名单。 + model := strings.ToLower(normalizeClaudeCodeLongContextModel(strings.TrimSpace(requestedModel))) + if model == "" { + return true // 空模型交由上层必填校验处理 + } + for _, servable := range deepseekServableModels { + if model == servable { + 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_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go index 75e8a07463..2fe1e28ee2 100644 --- a/backend/internal/service/openai_model_mapping_test.go +++ b/backend/internal/service/openai_model_mapping_test.go @@ -429,6 +429,30 @@ func TestNormalizeOpenAIModelForUpstream(t *testing.T) { model: "gpt-4.1", want: "gpt-4.1", }, + { + name: "deepseek strips claude code long context suffix", + account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek}, + model: "deepseek-flash[1m]", + want: "deepseek-flash", + }, + { + name: "deepseek strips duplicated long context suffix", + account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek}, + model: "deepseek-flash[1M][1m]", + want: "deepseek-flash", + }, + { + name: "deepseek preserves plain model", + account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek}, + model: "deepseek-flash", + want: "deepseek-flash", + }, + { + name: "non deepseek preserves long context suffix", + account: &Account{Type: AccountTypeAPIKey, Platform: PlatformOpenAI}, + model: "deepseek-flash[1m]", + want: "deepseek-flash[1m]", + }, } for _, tt := range tests {