fix(claude-desktop): strip [1m] suffix before proxy route lookup

Claude Desktop appends a local [1m] marker to the model name when the
1M-context beta is active (e.g. claude-opus-4-8[1m]). The proxy route
matcher compared this raw name against clean route IDs, so every match
tier failed and the is_claude_safe_model_id guard also blocked the role
keyword fallback, surfacing as route_unknown (HTTP 400) when switching
to a 1M-capable model mid-conversation.

Strip the [1m] suffix inside map_proxy_request_model before lookup so
exact/alias/legacy/role matching all see the clean ID, while keeping the
original name in the route_unknown error for diagnostics. The upstream
request still carries the mapped real model; 1M capability is negotiated
via the anthropic-beta header, not the model-name suffix.

Fixes #3588
This commit is contained in:
Jason
2026-06-02 23:51:27 +08:00
parent 6692343d1e
commit 084857ce25
+60 -13
View File
@@ -664,7 +664,7 @@ pub fn model_list_response(provider: &Provider) -> Result<Value, AppError> {
}
pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<Value, AppError> {
let requested = body
let requested_raw = body
.get("model")
.and_then(Value::as_str)
.map(str::trim)
@@ -677,6 +677,7 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
"Claude Desktop request is missing the model field",
)
})?;
let requested = strip_one_m_suffix_for_route_lookup(&requested_raw);
let routes = proxy_model_routes(provider)?;
let upstream_model = routes
@@ -685,20 +686,21 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
.or_else(|| {
routes
.iter()
.find(|r| is_compatible_opus_route_alias(&r.route_id, &requested))
.find(|r| is_compatible_opus_route_alias(&r.route_id, requested))
})
.map(|route| route.upstream_model.clone())
.or_else(|| legacy_raw_route_upstream_model(provider, &requested))
.or_else(|| legacy_raw_route_upstream_model(provider, requested))
.or_else(|| {
// 角色关键词回落:Claude Desktop 的部分调用(如子 agent)会请求带发布
// 日期后缀的完整官方名(claude-haiku-4-5-20251001),与 manifest 暴露的
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/sonnet 归类
// 到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
// 仅对 Claude Desktop 认可的安全模型名回落(排除 [1m] 标记等非法形式)。
if !is_claude_safe_model_id(&requested) {
// 匹配前已剥离本地 [1m] 标记;这里仍只对 Claude Desktop 认可的
// 安全模型名回落,避免非 Claude route 被误映射。
if !is_claude_safe_model_id(requested) {
return None;
}
let role = claude_role_keyword(&requested)?;
let role = claude_role_keyword(requested)?;
routes
.iter()
.find(|route| claude_role_keyword(&route.route_id) == Some(role))
@@ -707,8 +709,8 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
.ok_or_else(|| {
AppError::localized(
"claude_desktop.provider.route_unknown",
format!("Claude Desktop 模型路由未配置: {requested}"),
format!("Claude Desktop model route is not configured: {requested}"),
format!("Claude Desktop 模型路由未配置: {requested_raw}"),
format!("Claude Desktop model route is not configured: {requested_raw}"),
)
})?;
@@ -719,6 +721,18 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
Ok(body)
}
fn strip_one_m_suffix_for_route_lookup(model: &str) -> &str {
let trimmed = model.trim();
let marker = ONE_M_CONTEXT_MARKER.as_bytes();
let bytes = trimmed.as_bytes();
if bytes.len() >= marker.len()
&& bytes[bytes.len() - marker.len()..].eq_ignore_ascii_case(marker)
{
return trimmed[..trimmed.len() - marker.len()].trim_end();
}
trimmed
}
fn legacy_raw_route_upstream_model(provider: &Provider, requested: &str) -> Option<String> {
provider
.meta
@@ -1820,15 +1834,48 @@ mod tests {
}
#[test]
fn claude_desktop_proxy_rejects_1m_suffix_route() {
let provider = proxy_provider("proxy");
fn claude_desktop_proxy_strips_1m_suffix_before_route_lookup() {
let mut provider = proxy_provider("proxy");
provider
.meta
.as_mut()
.expect("meta")
.claude_desktop_model_routes = std::collections::HashMap::from([
(
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-sonnet".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
(
"claude-opus-4-8".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-opus".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
]);
let err = map_proxy_request_model(
let mapped = map_proxy_request_model(
json!({"model": "claude-opus-4-8[1m]", "messages": []}),
&provider,
)
.expect("compact 1M suffix should map to Opus route");
assert_eq!(mapped["model"], json!("upstream-opus"));
let mapped = map_proxy_request_model(
json!({"model": "claude-sonnet-4-6 [1M]", "messages": []}),
&provider,
)
.expect_err("1M suffix route should not be accepted");
assert!(err.to_string().contains("claude-sonnet-4-6 [1M]"));
.expect("spaced uppercase 1M suffix should map to Sonnet route");
assert_eq!(mapped["model"], json!("upstream-sonnet"));
let err = map_proxy_request_model(json!({"model": "gpt-5[1m]", "messages": []}), &provider)
.expect_err("non-Claude route should still fail after stripping 1M suffix");
assert!(err.to_string().contains("gpt-5[1m]"));
}
#[test]