mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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<RouteRow, "rowId">;
|
||||
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<string, unknown> | undefined,
|
||||
@@ -130,42 +174,30 @@ function clonePlainRecord(value: unknown): Record<string, unknown> {
|
||||
return { ...(value as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
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<string>) {
|
||||
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<string>) {
|
||||
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<string, ClaudeDesktopModelRoute> | 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({
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("claudeDesktop.routeMapHint", {
|
||||
defaultValue:
|
||||
"左侧决定 Claude Desktop 模型菜单里的可见模型 ID;1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。",
|
||||
"选择模型角色后,CC Switch 会自动生成 Claude Desktop 兼容路由;菜单显示名可以写 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden grid-cols-[1fr_1fr_116px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||
<div className="hidden grid-cols-[140px_1fr_1fr_116px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||
<span>
|
||||
{t("claudeDesktop.routeModelLabel", {
|
||||
defaultValue: "Desktop 显示模型",
|
||||
defaultValue: "模型角色",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("claudeDesktop.labelOverrideLabel", {
|
||||
defaultValue: "菜单显示名",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
@@ -889,25 +968,44 @@ export function ClaudeDesktopProviderForm({
|
||||
{routes.map((route, index) => (
|
||||
<div
|
||||
key={route.rowId}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_116px_36px]"
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[140px_1fr_1fr_116px_36px]"
|
||||
>
|
||||
<div className="flex">
|
||||
<span className="inline-flex h-9 items-center rounded-l-md border border-r-0 border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{CLAUDE_ROUTE_PREFIX}
|
||||
</span>
|
||||
<Input
|
||||
value={desktopRouteInputValue(route.route)}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, {
|
||||
route: desktopRouteIdFromInput(
|
||||
event.target.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder="gpt-5.5"
|
||||
className="rounded-l-none"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={routeRoleFromId(route.route)}
|
||||
onValueChange={(value) =>
|
||||
updateRouteRole(index, value as RouteRole)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sonnet">
|
||||
{t("claudeDesktop.routeRoleSonnet", {
|
||||
defaultValue: "Sonnet",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="opus">
|
||||
{t("claudeDesktop.routeRoleOpus", {
|
||||
defaultValue: "Opus",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="haiku">
|
||||
{t("claudeDesktop.routeRoleHaiku", {
|
||||
defaultValue: "Haiku",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={route.labelOverride}
|
||||
onChange={(event) =>
|
||||
updateRoute(index, {
|
||||
labelOverride: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="DeepSeek V4 Pro"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={route.model}
|
||||
@@ -923,8 +1021,13 @@ export function ClaudeDesktopProviderForm({
|
||||
onSelect={(id) =>
|
||||
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,
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -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<string>();
|
||||
return [sonnet, opus, haiku]
|
||||
.map((upstreamModel) => ({
|
||||
routeId: routeIdFromModel(upstreamModel),
|
||||
const seenUpstream = new Set<string>();
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 設定の書き込みはまだサポートされていません。",
|
||||
|
||||
@@ -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 配置写入。",
|
||||
|
||||
+1
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user