refactor(claude-desktop): lock route IDs to sonnet/opus/haiku roles

Adapt to Claude Desktop 1.6259.1+ fail-all validation which only
accepts claude-(sonnet|opus|haiku)-* route IDs. Branded model names
(DeepSeek, Kimi, GLM, etc.) now live in a new labelOverride field
instead of being embedded in route IDs.

- Backend auto-repairs legacy unsafe routes to the next free
  sonnet/opus/haiku slot instead of erroring
- Frontend swaps the free-form route input for a role dropdown plus
  menu display name field
- Add CLAUDE_DESKTOP_ROLE_ROUTE_IDS as the single source of truth
  for role-to-route mapping; presets and form both consume it
- Drop the dead displayName alias on ClaudeDesktopModelRoute and the
  ineffective /v1/models display_name injection (UI ignores it)
- Update i18n (en/ja/zh) and form focus test for the new fields
This commit is contained in:
Jason
2026-05-13 15:22:23 +08:00
parent edf28b6422
commit 84bac6dce6
10 changed files with 493 additions and 233 deletions
+187 -106
View File
@@ -29,6 +29,45 @@ pub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = "anthropic/claude-";
/// cc-switch 历史约定的 1M 上下文标记(ASCII 小写形式,匹配时用 `eq_ignore_ascii_case`)。
pub const LEGACY_ONE_M_MARKER: &str = "[1m]";
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 {
@@ -98,12 +137,14 @@ pub struct ClaudeDesktopStatus {
pub struct ResolvedModelRoute {
pub route_id: String,
pub upstream_model: String,
pub label_override: Option<String>,
pub supports_1m: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InferenceModelSpec {
name: String,
label_override: Option<String>,
supports_1m: bool,
}
@@ -217,57 +258,31 @@ pub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode {
pub fn is_claude_safe_model_id(model: &str) -> bool {
let normalized = model.trim().to_ascii_lowercase();
(normalized.starts_with(CLAUDE_ROUTE_PREFIX)
|| normalized.starts_with(ANTHROPIC_CLAUDE_ROUTE_PREFIX))
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(LEGACY_ONE_M_MARKER)
}
/// 把上游 model 名标准化为 Claude Desktop 路由表 key(即 UI 左列内容)。
/// 复现前端 `desktopRouteIdFromModel` + `normalizeDesktopRouteSuffix` 的规则:
/// 1. trim
/// 2. 剥离已有 `anthropic/claude-` 或 `claude-` 前缀(长前缀优先匹配)
/// 3. 去 `models/` 前缀(Gemini 风格 `models/gemini-2.0-flash`
/// 4. 把空格 / 制表 / 正反斜杠替换成 `-`
/// 5. 拼上 `claude-` 前缀
///
/// 示例:
/// "MiniMax-M2" → "claude-MiniMax-M2"
/// "models/gemini-2.0-flash" → "claude-gemini-2.0-flash"
/// "claude-sonnet-4-5-20250929" → "claude-sonnet-4-5-20250929"(剥后再拼,不双重)
/// "anthropic/claude-3-5-sonnet" → "claude-3-5-sonnet"(与前端一致:anthropic/claude- 被替换为 claude-
pub fn derive_desktop_route_id(upstream_model: &str) -> String {
let trimmed = upstream_model.trim();
let lower = trimmed.to_ascii_lowercase();
let suffix = if lower.starts_with(ANTHROPIC_CLAUDE_ROUTE_PREFIX) {
&trimmed[ANTHROPIC_CLAUDE_ROUTE_PREFIX.len()..]
} else if lower.starts_with(CLAUDE_ROUTE_PREFIX) {
&trimmed[CLAUDE_ROUTE_PREFIX.len()..]
} else {
trimmed
};
let without_models_prefix = suffix
.strip_prefix("models/")
.or_else(|| suffix.strip_prefix("Models/"))
.unwrap_or(suffix);
let normalized: String = without_models_prefix
.chars()
.map(|c| {
if matches!(c, ' ' | '\t' | '/' | '\\') {
'-'
} else {
c
}
})
.collect();
format!("{CLAUDE_ROUTE_PREFIX}{normalized}")
&& !NON_ANTHROPIC_ROUTE_MARKERS
.iter()
.any(|marker| normalized.contains(marker))
}
fn inference_model_json(spec: &InferenceModelSpec) -> Value {
if spec.supports_1m {
json!({
"name": spec.name,
"supports1m": true,
})
if spec.supports_1m || spec.label_override.is_some() {
let mut item = json!({ "name": spec.name });
if let Some(label_override) = spec.label_override.as_deref() {
item["labelOverride"] = json!(label_override);
}
if spec.supports_1m {
item["supports1m"] = json!(true);
}
item
} else {
Value::String(spec.name.clone())
}
@@ -507,12 +522,22 @@ fn direct_inference_model_specs(provider: &Provider) -> Result<Vec<InferenceMode
if !is_claude_safe_model_id(route_id) {
return Err(AppError::localized(
"claude_desktop.provider.route_invalid",
format!("Claude Desktop 直连模型必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}"),
format!("Claude Desktop direct model must use a claude-* or anthropic/claude-* name: {route_id}"),
format!(
"Claude Desktop 直连模型必须使用 claude-* anthropic/claude-* 名称: {route_id}"
),
format!(
"Claude Desktop direct model must use a claude-* or anthropic/claude-* name: {route_id}"
),
));
}
result.push(InferenceModelSpec {
name: route_id.to_string(),
label_override: route
.label_override
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
supports_1m,
});
}
@@ -541,24 +566,39 @@ pub fn proxy_model_routes(provider: &Provider) -> Result<Vec<ResolvedModelRoute>
)
})?;
let reserved_route_ids = routes
.keys()
.map(|route_id| route_id.trim())
.filter(|route_id| is_claude_safe_model_id(route_id))
.map(str::to_string)
.collect::<std::collections::HashSet<_>>();
let mut result = Vec::new();
for (route_id, route) in routes {
let mut entries = routes.iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
for (route_id, route) in entries {
let supports_1m = route.supports_1m.unwrap_or(false);
let route_id = route_id.trim();
let upstream_model = route.model.trim();
if route_id.is_empty() || upstream_model.is_empty() {
continue;
}
if !is_claude_safe_model_id(route_id) {
return Err(AppError::localized(
"claude_desktop.provider.route_invalid",
format!("Claude Desktop 模型路由必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}"),
format!("Claude Desktop model route must use a claude-* or anthropic/claude-* name: {route_id}"),
));
}
let repaired_route_id = if is_claude_safe_model_id(route_id) {
route_id.to_string()
} else {
next_catalog_safe_route_id(&result, &reserved_route_ids)
};
result.push(ResolvedModelRoute {
route_id: route_id.to_string(),
route_id: repaired_route_id,
upstream_model: upstream_model.to_string(),
label_override: route
.label_override
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
(!is_claude_safe_model_id(route_id)).then(|| upstream_model.to_string())
}),
supports_1m,
});
}
@@ -577,6 +617,32 @@ pub fn proxy_model_routes(provider: &Provider) -> Result<Vec<ResolvedModelRoute>
Ok(result)
}
fn next_catalog_safe_route_id(
existing: &[ResolvedModelRoute],
reserved: &std::collections::HashSet<String>,
) -> String {
if let Some(default_route) = DEFAULT_PROXY_ROUTES
.iter()
.map(|route| route.route_id)
.find(|route_id| {
!reserved.contains(*route_id)
&& !existing.iter().any(|route| route.route_id == *route_id)
})
{
return default_route.to_string();
}
let mut index = 2usize;
loop {
let route_id = format!("{}-r{index}", DEFAULT_PROXY_ROUTES[0].route_id);
if !reserved.contains(&route_id) && !existing.iter().any(|route| route.route_id == route_id)
{
return route_id;
}
index += 1;
}
}
pub fn model_list_response(provider: &Provider) -> Result<Value, AppError> {
let routes = proxy_model_routes(provider)?;
let data: Vec<Value> = routes
@@ -714,6 +780,7 @@ fn apply_provider_to_paths_inner(
.iter()
.map(|route| InferenceModelSpec {
name: route.route_id.clone(),
label_override: route.label_override.clone(),
supports_1m: route.supports_1m,
})
.collect::<Vec<_>>();
@@ -1102,6 +1169,7 @@ mod tests {
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
model: "kimi-k2".to_string(),
label_override: Some("Kimi K2".to_string()),
supports_1m: Some(true),
},
)]),
@@ -1126,9 +1194,10 @@ mod tests {
api_format: Some(api_format.to_string()),
provider_type: Some(provider_type.to_string()),
claude_desktop_model_routes: std::collections::HashMap::from([(
"claude-gpt-5-4".to_string(),
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
model: "gpt-5.4".to_string(),
label_override: Some("GPT-5.4".to_string()),
supports_1m: Some(false),
},
)]),
@@ -1143,9 +1212,10 @@ mod tests {
claude_desktop_mode: Some(ClaudeDesktopMode::Direct),
api_format: Some("anthropic".to_string()),
claude_desktop_model_routes: std::collections::HashMap::from([(
"claude-deepseek-chat".to_string(),
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
model: "claude-deepseek-chat".to_string(),
model: "claude-sonnet-4-6".to_string(),
label_override: None,
supports_1m: Some(true),
},
)]),
@@ -1203,7 +1273,7 @@ mod tests {
);
assert_eq!(
profile["inferenceModels"],
json!([{ "name": "claude-deepseek-chat", "supports1m": true }])
json!([{ "name": "claude-sonnet-4-6", "supports1m": true }])
);
}
@@ -1229,7 +1299,7 @@ mod tests {
.starts_with("ccs-"));
assert_eq!(
profile["inferenceModels"],
json!([{ "name": "claude-sonnet-4-6", "supports1m": true }])
json!([{ "name": "claude-sonnet-4-6", "labelOverride": "Kimi K2", "supports1m": true }])
);
assert!(!profile.to_string().contains("kimi-k2"));
}
@@ -1253,7 +1323,10 @@ mod tests {
profile["inferenceGatewayBaseUrl"],
json!("http://127.0.0.1:15721/claude-desktop")
);
assert_eq!(profile["inferenceModels"], json!(["claude-gpt-5-4"]));
assert_eq!(
profile["inferenceModels"],
json!([{ "name": "claude-sonnet-4-6", "labelOverride": "GPT-5.4" }])
);
}
}
@@ -1277,6 +1350,51 @@ mod tests {
assert!(err.to_string().contains("claude-opus-4-7"));
}
#[test]
fn claude_desktop_proxy_repairs_legacy_unsafe_route_without_colliding() {
let mut provider = proxy_provider("proxy");
provider.meta = Some(ProviderMeta {
claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),
api_format: Some("openai_chat".to_string()),
claude_desktop_model_routes: std::collections::HashMap::from([
(
"claude-deepseek-v4-pro".to_string(),
ClaudeDesktopModelRoute {
model: "deepseek-v4-pro".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
(
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
model: "claude-sonnet-4-6".to_string(),
label_override: None,
supports_1m: Some(false),
},
),
]),
..Default::default()
});
let routes = proxy_model_routes(&provider).expect("routes");
assert_eq!(routes.len(), 2);
let repaired = routes
.iter()
.find(|route| route.upstream_model == "deepseek-v4-pro")
.expect("repaired route");
assert_eq!(repaired.route_id, "claude-opus-4-7");
assert_eq!(repaired.label_override.as_deref(), Some("deepseek-v4-pro"));
assert!(repaired.supports_1m);
let mapped = map_proxy_request_model(
json!({"model": "claude-opus-4-7", "messages": []}),
&provider,
)
.expect("map repaired route");
assert_eq!(mapped["model"], json!("deepseek-v4-pro"));
}
#[test]
fn claude_desktop_proxy_rejects_1m_suffix_route() {
let provider = proxy_provider("proxy");
@@ -1293,6 +1411,11 @@ 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-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(" claude-sonnet-4-6 "));
}
@@ -1434,46 +1557,4 @@ mod tests {
);
assert!(!is_compatible_direct_provider(&missing_bearer));
}
// === derive_desktop_route_id ===
#[test]
fn derive_basic_adds_prefix() {
assert_eq!(derive_desktop_route_id("MiniMax-M2"), "claude-MiniMax-M2");
}
#[test]
fn derive_strips_gemini_prefix() {
assert_eq!(
derive_desktop_route_id("models/gemini-2.0-flash"),
"claude-gemini-2.0-flash"
);
}
#[test]
fn derive_keeps_existing_claude_prefix() {
assert_eq!(
derive_desktop_route_id("claude-sonnet-4-5-20250929"),
"claude-sonnet-4-5-20250929"
);
}
#[test]
fn derive_strips_anthropic_claude_prefix_uses_claude() {
// 与前端 normalizeDesktopRouteSuffix 一致:剥离 anthropic/claude- 前缀后用 claude- 替代
assert_eq!(
derive_desktop_route_id("anthropic/claude-3-5-sonnet"),
"claude-3-5-sonnet"
);
}
#[test]
fn derive_replaces_whitespace_and_slash() {
assert_eq!(derive_desktop_route_id("my model/v2"), "claude-my-model-v2");
}
#[test]
fn derive_preserves_case_and_dot() {
assert_eq!(derive_desktop_route_id("GLM-4.6"), "claude-GLM-4.6");
}
}
+71 -21
View File
@@ -260,6 +260,7 @@ pub(crate) fn suggested_claude_desktop_routes(
fn add_route(
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
env: &serde_json::Map<String, serde_json::Value>,
route_key: &str,
env_key: &str,
supports_1m_default: bool,
) {
@@ -287,27 +288,58 @@ pub(crate) fn suggested_claude_desktop_routes(
return;
}
let effective_supports_1m = supports_1m_default || has_1m_marker;
let route_key = crate::claude_desktop_config::derive_desktop_route_id(stripped_model);
let label_override =
(!crate::claude_desktop_config::is_claude_safe_model_id(stripped_model))
.then(|| stripped_model.to_string());
if let Some(existing) = routes
.values_mut()
.find(|existing| existing.model == stripped_model)
{
let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m;
existing.supports_1m = Some(merged);
if existing.label_override.is_none() {
existing.label_override = label_override;
}
return;
}
routes
.entry(route_key)
.entry(route_key.to_string())
.and_modify(|existing| {
let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m;
existing.supports_1m = Some(merged);
if existing.label_override.is_none() {
existing.label_override = label_override.clone();
}
})
.or_insert_with(|| crate::provider::ClaudeDesktopModelRoute {
model: stripped_model.to_string(),
label_override,
supports_1m: Some(effective_supports_1m),
});
}
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES {
add_route(&mut routes, env, spec.env_key, supports_1m_default);
add_route(
&mut routes,
env,
spec.route_id,
spec.env_key,
supports_1m_default,
);
}
// 三个 default env_key 全空时用 ANTHROPIC_MODEL 派生兜底路由。
if routes.is_empty() {
add_route(&mut routes, env, "ANTHROPIC_MODEL", supports_1m_default);
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0].route_id;
add_route(
&mut routes,
env,
primary_route,
"ANTHROPIC_MODEL",
supports_1m_default,
);
}
(!routes.is_empty()).then_some(routes)
@@ -729,15 +761,15 @@ mod import_claude_desktop_tests {
None,
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
// env 已带 claude- 前缀,派生 key 不重复加前缀
let r = routes
.get("claude-sonnet-4-5-20250929")
.expect("derived route present");
.get("claude-sonnet-4-6")
.expect("sonnet route present");
assert_eq!(r.model, "claude-sonnet-4-5-20250929");
assert!(
!r.model.to_ascii_lowercase().contains("[1m]"),
"model must not contain [1m] suffix"
);
assert_eq!(r.label_override, None);
assert_eq!(r.supports_1m, Some(true));
}
@@ -750,8 +782,11 @@ mod import_claude_desktop_tests {
None,
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes.get("claude-kimi-k2").expect("derived route present");
let r = routes
.get("claude-sonnet-4-6")
.expect("sonnet route present");
assert_eq!(r.model, "kimi-k2");
assert_eq!(r.label_override.as_deref(), Some("kimi-k2"));
// 默认 provider_type 缺省 → supports_1m_default = true
assert_eq!(r.supports_1m, Some(true));
}
@@ -767,9 +802,10 @@ mod import_claude_desktop_tests {
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes
.get("claude-gpt-5-codex")
.expect("derived route present");
.get("claude-sonnet-4-6")
.expect("sonnet route present");
assert_eq!(r.model, "gpt-5-codex");
assert_eq!(r.label_override.as_deref(), Some("gpt-5-codex"));
assert_eq!(r.supports_1m, Some(true));
}
@@ -783,9 +819,10 @@ mod import_claude_desktop_tests {
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes
.get("claude-gpt-5-codex")
.expect("derived route present");
.get("claude-sonnet-4-6")
.expect("sonnet route present");
assert_eq!(r.model, "gpt-5-codex");
assert_eq!(r.label_override.as_deref(), Some("gpt-5-codex"));
assert_eq!(r.supports_1m, Some(false));
}
@@ -802,9 +839,10 @@ mod import_claude_desktop_tests {
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
assert_eq!(routes.len(), 1, "three aliases → one merged route");
let r = routes
.get("claude-MiniMax-M2")
.get("claude-sonnet-4-6")
.expect("merged route present");
assert_eq!(r.model, "MiniMax-M2");
assert_eq!(r.label_override.as_deref(), Some("MiniMax-M2"));
}
#[test]
@@ -821,7 +859,7 @@ mod import_claude_desktop_tests {
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
assert_eq!(routes.len(), 1);
let r = routes
.get("claude-MiniMax-M2")
.get("claude-sonnet-4-6")
.expect("merged route present");
assert_eq!(r.supports_1m, Some(true));
}
@@ -838,11 +876,16 @@ mod import_claude_desktop_tests {
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
assert_eq!(routes.len(), 3);
assert_eq!(routes.get("claude-GLM-4.6").unwrap().model, "GLM-4.6");
assert_eq!(routes.get("claude-GLM-4-Air").unwrap().model, "GLM-4-Air");
assert_eq!(routes.get("claude-sonnet-4-6").unwrap().model, "GLM-4.6");
assert_eq!(routes.get("claude-opus-4-7").unwrap().model, "GLM-4-Air");
assert_eq!(routes.get("claude-haiku-4-5").unwrap().model, "GLM-4-Flash");
assert_eq!(
routes.get("claude-GLM-4-Flash").unwrap().model,
"GLM-4-Flash"
routes
.get("claude-sonnet-4-6")
.unwrap()
.label_override
.as_deref(),
Some("GLM-4.6")
);
}
@@ -858,9 +901,10 @@ mod import_claude_desktop_tests {
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
assert_eq!(routes.len(), 1);
let r = routes
.get("claude-kimi-k2")
.get("claude-sonnet-4-6")
.expect("fallback route present");
assert_eq!(r.model, "kimi-k2");
assert_eq!(r.label_override.as_deref(), Some("kimi-k2"));
}
#[test]
@@ -872,8 +916,14 @@ mod import_claude_desktop_tests {
None,
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
// key 应是 "claude-sonnet-4-5-20250929",不出现双重 claude- 前缀
assert!(routes.contains_key("claude-sonnet-4-5-20250929"));
assert!(routes.contains_key("claude-sonnet-4-6"));
assert!(!routes.contains_key("claude-claude-sonnet-4-5-20250929"));
assert_eq!(
routes
.get("claude-sonnet-4-6")
.expect("route")
.label_override,
None
);
}
}
+3
View File
@@ -230,6 +230,9 @@ pub enum ClaudeDesktopMode {
pub struct ClaudeDesktopModelRoute {
/// 真实上游模型名,只保存在 CC Switch 内部,不写入 Claude Desktop profile。
pub model: String,
/// Claude Desktop 模型菜单显示名;写入 profile 的 `labelOverride`。
#[serde(rename = "labelOverride", skip_serializing_if = "Option::is_none")]
pub label_override: Option<String>,
/// Claude Desktop 3P 识别的 1M 上下文能力标记。
#[serde(rename = "supports1m", skip_serializing_if = "Option::is_none")]
pub supports_1m: Option<bool>,