Align Claude Desktop model mapping with Claude Code three-role tiers

Claude Desktop's 3P validation only accepts claude-{sonnet,opus,haiku}-*
role IDs, so providers must map every tier. Bring the Desktop mapping flow
in line with Claude Code and fix the fallout that broke sub-agent Haiku calls.

- Proxy form: replace the dynamic route list with fixed Sonnet/Opus/Haiku
  tiers; blank tiers backfill from the first filled tier (Sonnet first) on
  submit and inherit its supports1m flag
- Backend: add a role-keyword fallback in map_proxy_request_model so dated
  official names (e.g. claude-haiku-4-5-20251001) resolve to the right tier,
  guarded by is_claude_safe_model_id so [1m]-suffixed IDs stay rejected
- Tighten is_claude_safe_model_id / isClaudeSafeRoute to reject degenerate
  role IDs like "claude-sonnet-"
- Fix the seed-effect race where normalizing empty routes to three blank
  tiers blocked the default-route backfill
- Sync switch hints, placeholders, and the zh/en/ja/zh-TW locales to the
  three-role-ID rule
- Update zh/en/ja user manual (2.1, 2.6), calling out legacy Claude IDs
  (claude-3-5-sonnet-...) as a rejected example

Tests: 282 frontend + 34 backend claude_desktop; typecheck, clippy, fmt clean.
This commit is contained in:
Jason
2026-05-29 22:27:47 +08:00
parent 058c9fb8e5
commit 94cc3d103a
13 changed files with 574 additions and 363 deletions
+169 -67
View File
@@ -35,45 +35,6 @@ pub const ONE_M_CONTEXT_MARKER: &str = "[1m]";
const CURRENT_OPUS_ROUTE_ID: &str = "claude-opus-4-8";
const LEGACY_OPUS_ROUTE_ID: &str = "claude-opus-4-7";
const NON_ANTHROPIC_ROUTE_MARKERS: &[&str] = &[
"ark-code",
"astron",
"command-r",
"deepseek",
"doubao",
"gemini",
"gemma",
"glm",
"gpt",
"grok",
"hermes",
"hy3",
"kimi",
"lfm",
"llama",
"longcat",
"mimo",
"minimax",
"mistral",
"mixtral",
"moonshot",
"nemotron",
"openai",
"qianfan",
"qwen",
"stepfun",
"seed-",
"hunyuan",
"nova-",
"ernie",
"codex",
"abab",
"jamba",
"arctic",
"solar",
"mercury",
];
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeDesktopDefaultRoute {
@@ -264,19 +225,24 @@ pub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode {
pub fn is_claude_safe_model_id(model: &str) -> bool {
let normalized = model.trim().to_ascii_lowercase();
let has_allowed_shape = (normalized.starts_with(CLAUDE_ROUTE_PREFIX)
&& normalized.len() > CLAUDE_ROUTE_PREFIX.len())
|| (normalized.starts_with(ANTHROPIC_CLAUDE_ROUTE_PREFIX)
&& normalized.len() > ANTHROPIC_CLAUDE_ROUTE_PREFIX.len())
|| matches!(normalized.as_str(), "sonnet" | "opus" | "haiku")
|| (normalized.starts_with("sonnet-") && normalized.len() > "sonnet-".len())
|| (normalized.starts_with("opus-") && normalized.len() > "opus-".len())
|| (normalized.starts_with("haiku-") && normalized.len() > "haiku-".len());
has_allowed_shape
&& !normalized.contains(ONE_M_CONTEXT_MARKER)
&& !NON_ANTHROPIC_ROUTE_MARKERS
.iter()
.any(|marker| normalized.contains(marker))
if normalized.contains(ONE_M_CONTEXT_MARKER) {
return false;
}
let Some(route_tail) = normalized
.strip_prefix(ANTHROPIC_CLAUDE_ROUTE_PREFIX)
.or_else(|| normalized.strip_prefix(CLAUDE_ROUTE_PREFIX))
else {
return false;
};
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
["sonnet-", "opus-", "haiku-"].iter().any(|prefix| {
route_tail
.strip_prefix(prefix)
.is_some_and(|rest| !rest.is_empty())
})
}
fn inference_model_json(spec: &InferenceModelSpec) -> Value {
@@ -713,26 +679,59 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
})?;
let routes = proxy_model_routes(provider)?;
let route = routes.iter().find(|r| r.route_id == requested).or_else(|| {
routes
.iter()
.find(|r| is_compatible_opus_route_alias(&r.route_id, &requested))
});
let Some(route) = route else {
return Err(AppError::localized(
"claude_desktop.provider.route_unknown",
format!("Claude Desktop 模型路由未配置: {requested}"),
format!("Claude Desktop model route is not configured: {requested}"),
));
};
let upstream_model = routes
.iter()
.find(|r| r.route_id == requested)
.or_else(|| {
routes
.iter()
.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(|| {
// 角色关键词回落: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) {
return None;
}
let role = claude_role_keyword(&requested)?;
routes
.iter()
.find(|route| claude_role_keyword(&route.route_id) == Some(role))
.map(|route| route.upstream_model.clone())
})
.ok_or_else(|| {
AppError::localized(
"claude_desktop.provider.route_unknown",
format!("Claude Desktop 模型路由未配置: {requested}"),
format!("Claude Desktop model route is not configured: {requested}"),
)
})?;
body["model"] = json!(route.upstream_model);
if should_normalize_mimo_anthropic_thinking_history(provider, &route.upstream_model) {
body["model"] = json!(upstream_model);
if should_normalize_mimo_anthropic_thinking_history(provider, &upstream_model) {
normalize_mimo_anthropic_thinking_history(&mut body);
}
Ok(body)
}
fn legacy_raw_route_upstream_model(provider: &Provider, requested: &str) -> Option<String> {
provider
.meta
.as_ref()?
.claude_desktop_model_routes
.iter()
.find(|(route_id, _)| route_id.trim() == requested)
.and_then(|(_, route)| {
let upstream_model = route.model.trim();
(!upstream_model.is_empty()).then(|| upstream_model.to_string())
})
}
fn is_compatible_opus_route_alias(route_id: &str, requested: &str) -> bool {
matches!(
(route_id, requested),
@@ -741,6 +740,22 @@ fn is_compatible_opus_route_alias(route_id: &str, requested: &str) -> bool {
)
}
/// 按角色关键词(opus / haiku / sonnet)归类一个 Claude 模型名/route_id。
/// 仅在命中明确角色词时返回 Some,未知模型返回 None(不回落,保持精确报错语义)。
/// 与前端 `routeRoleFromId` 同序(opus → haiku → sonnet)。
fn claude_role_keyword(model: &str) -> Option<&'static str> {
let normalized = model.to_ascii_lowercase();
if normalized.contains("opus") {
Some("opus")
} else if normalized.contains("haiku") {
Some("haiku")
} else if normalized.contains("sonnet") {
Some("sonnet")
} else {
None
}
}
fn should_normalize_mimo_anthropic_thinking_history(
provider: &Provider,
upstream_model: &str,
@@ -1540,6 +1555,63 @@ mod tests {
assert!(err.to_string().contains("claude-opus-4-8"));
}
#[test]
fn claude_desktop_proxy_maps_dated_role_alias_via_keyword() {
// 复现反馈:Claude Desktop 子 agent 请求带发布日期后缀的完整官方名
// claude-haiku-4-5-20251001),与 manifest 的简短 route_idclaude-haiku-4-5
// 不精确相等,旧逻辑会报 route_unknown。角色关键词回落应将其映射到 Haiku 档。
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: "deepseek-v4-pro".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
(
"claude-opus-4-8".to_string(),
ClaudeDesktopModelRoute {
model: "deepseek-v4-pro".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
(
"claude-haiku-4-5".to_string(),
ClaudeDesktopModelRoute {
model: "deepseek-v4-flash".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
]);
let mapped = map_proxy_request_model(
json!({"model": "claude-haiku-4-5-20251001", "messages": []}),
&provider,
)
.expect("dated Haiku alias should map via role keyword");
assert_eq!(mapped["model"], json!("deepseek-v4-flash"));
let mapped_sonnet = map_proxy_request_model(
json!({"model": "claude-sonnet-4-5-20250101", "messages": []}),
&provider,
)
.expect("dated Sonnet alias should map via role keyword");
assert_eq!(mapped_sonnet["model"], json!("deepseek-v4-pro"));
// 不含任何角色关键词的模型仍然报错,避免被误映射。
let err = map_proxy_request_model(json!({"model": "gpt-5"}), &provider)
.expect_err("model without a role keyword should still fail");
assert!(err.to_string().contains("gpt-5"));
}
#[test]
fn claude_desktop_proxy_accepts_opus_4_7_4_8_alias_during_rollout() {
let mut provider = proxy_provider("proxy");
@@ -1695,6 +1767,14 @@ mod tests {
supports_1m: Some(true),
},
),
(
"claude-old".to_string(),
ClaudeDesktopModelRoute {
model: "legacy-upstream".to_string(),
label_override: None,
supports_1m: Some(false),
},
),
(
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
@@ -1708,7 +1788,7 @@ mod tests {
});
let routes = proxy_model_routes(&provider).expect("routes");
assert_eq!(routes.len(), 2);
assert_eq!(routes.len(), 3);
let repaired = routes
.iter()
.find(|route| route.upstream_model == "deepseek-v4-pro")
@@ -1716,6 +1796,15 @@ mod tests {
assert_eq!(repaired.route_id, "claude-opus-4-8");
assert_eq!(repaired.label_override.as_deref(), Some("deepseek-v4-pro"));
assert!(repaired.supports_1m);
let repaired_old = routes
.iter()
.find(|route| route.upstream_model == "legacy-upstream")
.expect("legacy route should be repaired");
assert_eq!(repaired_old.route_id, "claude-haiku-4-5");
assert_eq!(
repaired_old.label_override.as_deref(),
Some("legacy-upstream")
);
let mapped = map_proxy_request_model(
json!({"model": "claude-opus-4-8", "messages": []}),
@@ -1723,6 +1812,11 @@ mod tests {
)
.expect("map repaired route");
assert_eq!(mapped["model"], json!("deepseek-v4-pro"));
let legacy_mapped =
map_proxy_request_model(json!({"model": "claude-old", "messages": []}), &provider)
.expect("map stale profile route");
assert_eq!(legacy_mapped["model"], json!("legacy-upstream"));
}
#[test]
@@ -1741,12 +1835,20 @@ mod tests {
fn claude_desktop_rejects_1m_suffix_as_model_id() {
assert!(!is_claude_safe_model_id("claude-sonnet-4-6 [1m]"));
assert!(!is_claude_safe_model_id(" claude-sonnet-4-6 [1M] "));
assert!(!is_claude_safe_model_id("claude-old"));
assert!(!is_claude_safe_model_id("claude-3-5-sonnet-20241022"));
assert!(!is_claude_safe_model_id("claude-deepseek-v4-pro"));
assert!(!is_claude_safe_model_id("claude-gpt-5-4"));
assert!(!is_claude_safe_model_id("claude-"));
assert!(!is_claude_safe_model_id("anthropic/claude-"));
assert!(!is_claude_safe_model_id("sonnet"));
assert!(!is_claude_safe_model_id("sonnet-"));
// 角色前缀后无实际标识的退化值必须拒绝
assert!(!is_claude_safe_model_id("claude-sonnet-"));
assert!(!is_claude_safe_model_id("claude-opus-"));
assert!(!is_claude_safe_model_id("anthropic/claude-haiku-"));
assert!(is_claude_safe_model_id(" claude-sonnet-4-6 "));
assert!(is_claude_safe_model_id("anthropic/claude-opus-4-8"));
}
#[test]