diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 1280551d0..5d53b1394 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -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, pub supports_1m: bool, } #[derive(Debug, Clone, PartialEq, Eq)] struct InferenceModelSpec { name: String, + label_override: Option, 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 Result ) })?; + 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::>(); let mut result = Vec::new(); - for (route_id, route) in routes { + let mut entries = routes.iter().collect::>(); + 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 Ok(result) } +fn next_catalog_safe_route_id( + existing: &[ResolvedModelRoute], + reserved: &std::collections::HashSet, +) -> 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 { let routes = proxy_model_routes(provider)?; let data: Vec = 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::>(); @@ -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"); - } } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index af133582a..28b1d6b17 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -260,6 +260,7 @@ pub(crate) fn suggested_claude_desktop_routes( fn add_route( routes: &mut std::collections::HashMap, env: &serde_json::Map, + 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 + ); } } diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 49c6cea3b..e019a323b 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -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, /// Claude Desktop 3P 识别的 1M 上下文能力标记。 #[serde(rename = "supports1m", skip_serializing_if = "Option::is_none")] pub supports_1m: Option, diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index e03626fa9..ec1d0f227 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -51,8 +51,10 @@ import type { } from "@/types"; import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets"; import { + CLAUDE_DESKTOP_ROLE_ROUTE_IDS, claudeDesktopProviderPresets, type ClaudeDesktopProviderPreset, + type ClaudeDesktopRoleId, } from "@/config/claudeDesktopProviderPresets"; import { fetchModelsForConfig, @@ -104,14 +106,56 @@ type RouteRow = { rowId: string; route: string; model: string; + labelOverride: string; supports1m: boolean; }; type RouteRowValues = Omit; +type RouteRole = ClaudeDesktopRoleId; const CLAUDE_ROUTE_PREFIX = "claude-"; const ANTHROPIC_CLAUDE_ROUTE_PREFIX = "anthropic/claude-"; const LEGACY_ONE_M_MARKER = "[1m]"; +const ROLE_ROUTE_IDS = CLAUDE_DESKTOP_ROLE_ROUTE_IDS; +const ROLE_ORDER: RouteRole[] = ["sonnet", "opus", "haiku"]; +const NON_ANTHROPIC_ROUTE_MARKERS = [ + "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", +]; function envString( settingsConfig: Record | undefined, @@ -130,42 +174,30 @@ function clonePlainRecord(value: unknown): Record { return { ...(value as Record) }; } -function desktopRouteInputValue(route: string) { - const trimmed = route.trim(); - const lower = trimmed.toLowerCase(); - if (lower.startsWith(CLAUDE_ROUTE_PREFIX)) { - return trimmed.slice(CLAUDE_ROUTE_PREFIX.length); - } - if (lower.startsWith(ANTHROPIC_CLAUDE_ROUTE_PREFIX)) { - return trimmed.slice(ANTHROPIC_CLAUDE_ROUTE_PREFIX.length); - } - return trimmed; +function routeRoleFromId(route: string): RouteRole { + const normalized = route.trim().toLowerCase(); + if (normalized.includes("opus")) return "opus"; + if (normalized.includes("haiku")) return "haiku"; + return "sonnet"; } -function normalizeDesktopRouteSuffix(value: string) { - const trimmed = value.trim(); - const lower = trimmed.toLowerCase(); - if (lower.startsWith(CLAUDE_ROUTE_PREFIX)) { - return trimmed.slice(CLAUDE_ROUTE_PREFIX.length); +function routeIdForRole(role: RouteRole, usedRoutes: Set) { + const baseRoute = ROLE_ROUTE_IDS[role]; + if (!usedRoutes.has(baseRoute)) return baseRoute; + + let index = 2; + while (usedRoutes.has(`${baseRoute}-r${index}`)) { + index += 1; } - if (lower.startsWith(ANTHROPIC_CLAUDE_ROUTE_PREFIX)) { - return trimmed.slice(ANTHROPIC_CLAUDE_ROUTE_PREFIX.length); - } - return trimmed; + return `${baseRoute}-r${index}`; } -function desktopRouteIdFromInput(value: string) { - const suffix = normalizeDesktopRouteSuffix(value).replace(/[/\\\s]+/g, "-"); - if (!suffix) return ""; - return `${CLAUDE_ROUTE_PREFIX}${suffix}`; -} - -function desktopRouteIdFromModel(model: string) { - const suffix = model - .trim() - .replace(/^models\//i, "") - .replace(/[/\\\s]+/g, "-"); - return desktopRouteIdFromInput(suffix); +function fallbackCatalogRouteId(usedRoutes: Set) { + const role = ROLE_ORDER.find((candidate) => { + const route = ROLE_ROUTE_IDS[candidate]; + return !usedRoutes.has(route); + }); + return routeIdForRole(role ?? "sonnet", usedRoutes); } function createRouteRow(row: RouteRowValues): RouteRow { @@ -178,21 +210,42 @@ function createRouteRow(row: RouteRowValues): RouteRow { function initialRouteRows( routes: Record | undefined, ): RouteRow[] { - return Object.entries(routes ?? {}).map(([route, value]) => - createRouteRow({ - route, - model: value.model ?? "", - supports1m: value.supports1m ?? false, - }), + const usedRoutes = new Set( + Object.keys(routes ?? {}).filter((route) => isClaudeSafeRoute(route)), ); + + return Object.entries(routes ?? {}).map(([route, value]) => { + const routeId = isClaudeSafeRoute(route) + ? route + : fallbackCatalogRouteId(usedRoutes); + usedRoutes.add(routeId); + + return createRouteRow({ + route: routeId, + model: value.model ?? "", + labelOverride: + value.labelOverride ?? + (!isClaudeSafeRoute(route) ? value.model || route : ""), + supports1m: value.supports1m ?? false, + }); + }); } function isClaudeSafeRoute(route: string) { const normalized = route.trim().toLowerCase(); if (normalized.includes(LEGACY_ONE_M_MARKER)) return false; - return [CLAUDE_ROUTE_PREFIX, ANTHROPIC_CLAUDE_ROUTE_PREFIX].some( - (prefix) => - normalized.startsWith(prefix) && normalized.length > prefix.length, + const hasAllowedShape = + [CLAUDE_ROUTE_PREFIX, ANTHROPIC_CLAUDE_ROUTE_PREFIX].some( + (prefix) => + normalized.startsWith(prefix) && normalized.length > prefix.length, + ) || + ["sonnet", "opus", "haiku"].includes(normalized) || + normalized.startsWith("sonnet-") || + normalized.startsWith("opus-") || + normalized.startsWith("haiku-"); + return ( + hasAllowedShape && + !NON_ANTHROPIC_ROUTE_MARKERS.some((marker) => normalized.includes(marker)) ); } @@ -204,6 +257,7 @@ function defaultRouteRows( createRouteRow({ route: route.routeId, model: index === 0 ? defaultModel : "", + labelOverride: "", supports1m: route.supports1m, }), ); @@ -219,13 +273,16 @@ function nextRouteRow(current: RouteRow[], defaults: RouteRow[]): RouteRow { return createRouteRow({ route: defaultRow.route, model: defaultRow.model, + labelOverride: defaultRow.labelOverride, supports1m: defaultRow.supports1m, }); } + const usedRoutes = new Set(current.map((route) => route.route)); return createRouteRow({ - route: "", + route: routeIdForRole("sonnet", usedRoutes), model: "", + labelOverride: "", supports1m: true, }); } @@ -399,6 +456,7 @@ export function ClaudeDesktopProviderForm({ createRouteRow({ route: r.routeId, model: r.upstreamModel, + labelOverride: r.labelOverride ?? "", supports1m: r.supports1m, }), ), @@ -444,6 +502,19 @@ export function ClaudeDesktopProviderForm({ ); }; + const updateRouteRole = (index: number, role: RouteRole) => { + setRoutes((current) => { + const usedRoutes = new Set( + current + .filter((_, i) => i !== index) + .map((row) => row.route) + .filter(Boolean), + ); + const route = routeIdForRole(role, usedRoutes); + return current.map((row, i) => (i === index ? { ...row, route } : row)); + }); + }; + const handleModelMappingChange = (checked: boolean) => { setMode(checked ? "proxy" : "direct"); if (checked) { @@ -555,6 +626,7 @@ export function ClaudeDesktopProviderForm({ ...route, route: route.route.trim(), model: route.model.trim(), + labelOverride: route.labelOverride.trim(), })) .filter((route) => route.route || route.model); @@ -614,6 +686,8 @@ export function ClaudeDesktopProviderForm({ >((acc, route) => { acc[route.route] = { model: route.model || route.route, + labelOverride: + route.labelOverride || (mode === "proxy" ? route.model : undefined), supports1m: route.supports1m || undefined, }; return acc; @@ -863,15 +937,20 @@ export function ClaudeDesktopProviderForm({

{t("claudeDesktop.routeMapHint", { defaultValue: - "左侧决定 Claude Desktop 模型菜单里的可见模型 ID;1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。", + "选择模型角色后,CC Switch 会自动生成 Claude Desktop 兼容路由;菜单显示名可以写 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。", })}

-
+
{t("claudeDesktop.routeModelLabel", { - defaultValue: "Desktop 显示模型", + defaultValue: "模型角色", + })} + + + {t("claudeDesktop.labelOverrideLabel", { + defaultValue: "菜单显示名", })} @@ -889,25 +968,44 @@ export function ClaudeDesktopProviderForm({ {routes.map((route, index) => (
-
- - {CLAUDE_ROUTE_PREFIX} - - - updateRoute(index, { - route: desktopRouteIdFromInput( - event.target.value, - ), - }) - } - placeholder="gpt-5.5" - className="rounded-l-none" - /> -
+ + + updateRoute(index, { + labelOverride: event.target.value, + }) + } + placeholder="DeepSeek V4 Pro" + />
updateRoute(index, { model: id, + labelOverride: route.labelOverride || id, route: - route.route || desktopRouteIdFromModel(id), + route.route || + routeIdForRole( + "sonnet", + new Set(routes.map((row) => row.route)), + ), }) } /> @@ -1006,6 +1109,7 @@ export function ClaudeDesktopProviderForm({ createRouteRow({ route: "", model: "", + labelOverride: "", supports1m: false, }), ]), diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index 97b9e4e2f..fe6c455fc 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -20,9 +20,23 @@ export type ClaudeDesktopApiFormat = export interface ClaudeDesktopRoutePreset { routeId: string; upstreamModel: string; + labelOverride?: string; supports1m: boolean; } +/** + * Claude Desktop 3P fail-all 校验只接受 `claude-(sonnet|opus|haiku)-*` 形式的 + * routeId(1.6259.1+,实测 2026-05-13)。所有预设工厂、表单角色下拉、 + * 后端 `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。 + */ +export const CLAUDE_DESKTOP_ROLE_ROUTE_IDS = { + sonnet: "claude-sonnet-4-6", + opus: "claude-opus-4-7", + haiku: "claude-haiku-4-5", +} as const; + +export type ClaudeDesktopRoleId = keyof typeof CLAUDE_DESKTOP_ROLE_ROUTE_IDS; + export interface ClaudeDesktopProviderPreset { name: string; nameKey?: string; @@ -49,18 +63,18 @@ export interface ClaudeDesktopProviderPreset { const passthroughRoutes = (supports1m = false): ClaudeDesktopRoutePreset[] => [ { - routeId: "claude-sonnet-4-6", - upstreamModel: "claude-sonnet-4-6", + routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet, + upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet, supports1m, }, { - routeId: "claude-opus-4-7", - upstreamModel: "claude-opus-4-7", + routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus, + upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus, supports1m, }, { - routeId: "claude-haiku-4-5", - upstreamModel: "claude-haiku-4-5", + routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku, + upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku, supports1m, }, ]; @@ -72,35 +86,25 @@ const mappedRoutes = ( supports1m = false, ): ClaudeDesktopRoutePreset[] => [ { - routeId: "claude-sonnet-4-6", + routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet, upstreamModel: sonnet, supports1m, }, { - routeId: "claude-opus-4-7", + routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus, upstreamModel: opus, supports1m, }, { - routeId: "claude-haiku-4-5", + routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku, upstreamModel: haiku, supports1m, }, ]; -const routeIdFromModel = (model: string) => { - const trimmed = model.trim(); - const lower = trimmed.toLowerCase(); - if (lower.startsWith("claude-") || lower.startsWith("anthropic/claude-")) { - return trimmed; - } - - return `claude-${trimmed.replace(/^models\//i, "").replace(/[/\\\s]+/g, "-")}`; -}; - /** - * 非 Claude 上游模型用此工厂:用 claude-* route ID 承载品牌化模型名, - * 让 Claude Desktop 菜单能从 ID 看到接近上游模型的名称。 + * 非 Claude 上游模型用此工厂:route ID 使用 Claude Desktop 能通过校验的 + * Sonnet/Opus/Haiku 路由,真实品牌名只写入 labelOverride 和 upstreamModel。 */ const brandedRoutes = ( sonnet: string, @@ -108,18 +112,23 @@ const brandedRoutes = ( haiku: string, supports1m = false, ): ClaudeDesktopRoutePreset[] => { - const seen = new Set(); - return [sonnet, opus, haiku] - .map((upstreamModel) => ({ - routeId: routeIdFromModel(upstreamModel), + const seenUpstream = new Set(); + return [ + { routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet, upstreamModel: sonnet }, + { routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus, upstreamModel: opus }, + { routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku, upstreamModel: haiku }, + ] + .map(({ routeId, upstreamModel }) => ({ + routeId, upstreamModel, + labelOverride: upstreamModel, supports1m, })) .filter((route) => { - if (seen.has(route.routeId)) { + if (seenUpstream.has(route.upstreamModel)) { return false; } - seen.add(route.routeId); + seenUpstream.add(route.upstreamModel); return true; }); }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 02c96e0bc..89dfdaaad 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -182,8 +182,12 @@ "modelMappingOffHint": "Use this when the provider already exposes and accepts claude-* / anthropic/claude-* model IDs through Anthropic Messages. Claude Desktop connects to the provider directly.", "modelMappingOnHint": "Claude Desktop currently restricts model IDs. If your provider offers non-Claude models, enable this switch and keep local routing running while in use.", "routeMapTitle": "Model mapping", - "routeMapHint": "The left field controls the Claude Desktop-visible model ID. 1M only declares 1M context support to Claude Desktop; requested model IDs are sent upstream exactly as entered on the right.", - "routeModelLabel": "Desktop display model", + "routeMapHint": "Choose a model role and CC Switch will generate a Claude Desktop-compatible route automatically. Use the menu display name for branded models such as DeepSeek or Kimi; requested model IDs are sent upstream exactly as entered.", + "routeModelLabel": "Model role", + "routeRoleSonnet": "Sonnet", + "routeRoleOpus": "Opus", + "routeRoleHaiku": "Haiku", + "labelOverrideLabel": "Menu display name", "upstreamModelLabel": "Requested model", "supports1mLabel": "Declare 1M", "supports1mShort": "1M", @@ -193,7 +197,7 @@ "directModelInvalid": "Direct models must use claude-* / anthropic/claude-* model IDs", "addModel": "Add model", "addRoute": "Add model", - "routeInvalid": "Please enter both the Desktop display model and requested model", + "routeInvalid": "Choose a model role and enter the requested model", "routesRequired": "At least one model mapping is required", "statusTitle": "Claude Desktop configuration needs attention", "statusUnsupported": "This platform does not support writing Claude Desktop 3P configuration yet.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fd3560fd3..1b684088b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -182,8 +182,12 @@ "modelMappingOffHint": "プロバイダーが Anthropic Messages で claude-* / anthropic/claude-* のモデル ID を公開し、そのまま受け付ける場合に使います。Claude Desktop はプロバイダーへ直接接続します。", "modelMappingOnHint": "Claude Desktop は現在モデル ID を制限しています。お使いのプロバイダーが Claude 系列以外のモデルを提供する場合、このスイッチを有効にし、使用中はローカルルーティングを起動したままにしてください。", "routeMapTitle": "モデルマッピング", - "routeMapHint": "左側は Claude Desktop のモデルメニューに表示されるモデル ID です。1M は Claude Desktop に 1M コンテキスト対応を宣言するだけで、上流へのリクエストモデル ID は右側の入力どおりに送信されます。", - "routeModelLabel": "Desktop 表示モデル", + "routeMapHint": "モデル役割を選ぶと、CC Switch が Claude Desktop 互換ルートを自動生成します。DeepSeek や Kimi などのブランド名はメニュー表示名に入れ、実際のリクエストモデルは右側の入力どおりに送信されます。", + "routeModelLabel": "モデル役割", + "routeRoleSonnet": "Sonnet", + "routeRoleOpus": "Opus", + "routeRoleHaiku": "Haiku", + "labelOverrideLabel": "メニュー表示名", "upstreamModelLabel": "リクエストモデル", "supports1mLabel": "1M 対応を宣言", "supports1mShort": "1M", @@ -193,7 +197,7 @@ "directModelInvalid": "直結モデルは claude-* / anthropic/claude-* のモデル ID を使う必要があります", "addModel": "モデルを追加", "addRoute": "モデルを追加", - "routeInvalid": "Desktop 表示モデルとリクエストモデルを入力してください", + "routeInvalid": "モデル役割を選択し、リクエストモデルを入力してください", "routesRequired": "少なくとも 1 つのモデルマッピングが必要です", "statusTitle": "Claude Desktop 設定の確認が必要です", "statusUnsupported": "このプラットフォームでは Claude Desktop 3P 設定の書き込みはまだサポートされていません。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6e44b418c..ace3e4941 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -182,8 +182,12 @@ "modelMappingOffHint": "适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。", "modelMappingOnHint": "Claude Desktop 目前对模型 ID 进行了限制,如果您的供应商提供的模型不是 Claude 系列模型,则需要打开本开关,并在使用过程中保持本地路由开启。", "routeMapTitle": "模型映射", - "routeMapHint": "左侧决定 Claude Desktop 模型菜单里的可见模型 ID;1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。", - "routeModelLabel": "Desktop 显示模型", + "routeMapHint": "选择模型角色后,CC Switch 会自动生成 Claude Desktop 兼容路由;菜单显示名可以写 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。", + "routeModelLabel": "模型角色", + "routeRoleSonnet": "Sonnet", + "routeRoleOpus": "Opus", + "routeRoleHaiku": "Haiku", + "labelOverrideLabel": "菜单显示名", "upstreamModelLabel": "实际请求模型", "supports1mLabel": "声明支持 1M", "supports1mShort": "1M", @@ -193,7 +197,7 @@ "directModelInvalid": "直连模型必须使用 claude-* / anthropic/claude-* 模型名", "addModel": "添加模型", "addRoute": "添加模型", - "routeInvalid": "请填写 Desktop 显示模型和实际请求模型", + "routeInvalid": "请选择模型角色并填写实际请求模型", "routesRequired": "至少填写一个模型映射", "statusTitle": "Claude Desktop 配置需要检查", "statusUnsupported": "当前平台暂不支持 Claude Desktop 3P 配置写入。", diff --git a/src/types.ts b/src/types.ts index d190c689d..bdecd38f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -133,8 +133,7 @@ export interface AuthBinding { export interface ClaudeDesktopModelRoute { model: string; - /** @deprecated Claude Desktop ignores this in the model menu; kept only to read old configs. */ - displayName?: string; + labelOverride?: string; supports1m?: boolean; } diff --git a/tests/components/ClaudeDesktopProviderForm.test.tsx b/tests/components/ClaudeDesktopProviderForm.test.tsx index 4b47def0c..8e510b92f 100644 --- a/tests/components/ClaudeDesktopProviderForm.test.tsx +++ b/tests/components/ClaudeDesktopProviderForm.test.tsx @@ -28,7 +28,7 @@ function renderForm( } describe("ClaudeDesktopProviderForm", () => { - it("编辑模型映射的 Desktop 模型 ID 时保持输入框焦点", () => { + it("编辑模型映射的菜单显示名时保持输入框焦点", () => { renderForm({ name: "Proxy Provider", settingsConfig: { @@ -47,15 +47,17 @@ describe("ClaudeDesktopProviderForm", () => { }, }); - const input = screen.getByPlaceholderText("gpt-5.5") as HTMLInputElement; + const input = screen.getByPlaceholderText( + "DeepSeek V4 Pro", + ) as HTMLInputElement; input.focus(); - fireEvent.change(input, { target: { value: "12345" } }); + fireEvent.change(input, { target: { value: "DeepSeek V4 Pro" } }); const currentInput = screen.getByPlaceholderText( - "gpt-5.5", + "DeepSeek V4 Pro", ) as HTMLInputElement; - expect(currentInput).toHaveValue("12345"); + expect(currentInput).toHaveValue("DeepSeek V4 Pro"); expect(document.activeElement).toBe(currentInput); });