fix(deepseek): 模型名校验不再默认兜底,未配置映射时按官方名单校验

- 未配置 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 的账号语义不变(映射优先)
This commit is contained in:
alfadb
2026-09-15 10:42:29 +08:00
parent badfad8b72
commit a939553c87
5 changed files with 153 additions and 1 deletions
+7
View File
@@ -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) {
@@ -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 {
@@ -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 {
@@ -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.
@@ -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 {