diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 3d67c8945..9c11d9ad1 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -21,7 +21,6 @@ const CONFIG_LIBRARY_DIR: &str = "configLibrary"; const GATEWAY_TOKEN_SETTING_KEY: &str = "claude_desktop_gateway_token"; const CLAUDE_DESKTOP_PROXY_PREFIX: &str = "/claude-desktop"; const DEFAULT_CREATED_AT: &str = "2024-01-01T00:00:00Z"; -const ONE_M_CONTEXT_SUFFIX: &str = " [1M]"; #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] @@ -95,6 +94,12 @@ pub struct ResolvedModelRoute { pub supports_1m: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct InferenceModelSpec { + name: String, + supports_1m: bool, +} + pub fn apply_provider(db: &Database, provider: &Provider) -> Result<(), AppError> { let paths = current_platform_paths()?; apply_provider_to_paths(db, provider, &paths) @@ -204,39 +209,22 @@ pub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode { } pub fn is_claude_safe_model_id(model: &str) -> bool { - let normalized = strip_one_m_context_suffix(model).to_ascii_lowercase(); - normalized.starts_with("claude-") || normalized.starts_with("anthropic/claude-") + let normalized = model.trim().to_ascii_lowercase(); + (normalized.starts_with("claude-") || normalized.starts_with("anthropic/claude-")) + && !normalized.contains("[1m]") } -fn strip_one_m_context_suffix(model: &str) -> String { - let trimmed = model.trim(); - let lower = trimmed.to_ascii_lowercase(); - if lower.ends_with("[1m]") { - trimmed[..trimmed.len().saturating_sub("[1M]".len())] - .trim_end() - .to_string() +fn inference_model_json(spec: &InferenceModelSpec) -> Value { + if spec.supports_1m { + json!({ + "name": spec.name, + "supports1m": true, + }) } else { - trimmed.to_string() + Value::String(spec.name.clone()) } } -fn has_one_m_context_suffix(model: &str) -> bool { - model.trim().to_ascii_lowercase().ends_with("[1m]") -} - -fn desktop_model_id(model_id: &str, supports_1m: bool) -> String { - let normalized = strip_one_m_context_suffix(model_id); - if supports_1m { - format!("{normalized}{ONE_M_CONTEXT_SUFFIX}") - } else { - normalized - } -} - -fn upstream_model_id(model_id: &str, supports_1m: bool) -> String { - desktop_model_id(model_id, supports_1m) -} - pub fn get_or_create_gateway_token(db: &Database) -> Result { if let Some(token) = db.get_setting(GATEWAY_TOKEN_SETTING_KEY)? { let trimmed = token.trim(); @@ -351,7 +339,7 @@ pub fn validate_direct_provider(provider: &Provider) -> Result<(), AppError> { } } - direct_inference_model_ids(provider)?; + direct_inference_model_specs(provider)?; direct_gateway_credentials(provider)?; Ok(()) } @@ -452,7 +440,7 @@ pub fn validate_provider(provider: &Provider) -> Result<(), AppError> { } } -pub fn direct_inference_model_ids(provider: &Provider) -> Result, AppError> { +fn direct_inference_model_specs(provider: &Provider) -> Result, AppError> { let Some(routes) = provider .meta .as_ref() @@ -463,23 +451,32 @@ pub fn direct_inference_model_ids(provider: &Provider) -> Result, Ap let mut result = Vec::new(); for (route_id, route) in routes { - let supports_1m = route.supports_1m.unwrap_or(false) || has_one_m_context_suffix(route_id); - let route_id = strip_one_m_context_suffix(route_id); + let supports_1m = route.supports_1m.unwrap_or(false); + let route_id = route_id.trim(); if route_id.is_empty() { continue; } - if !is_claude_safe_model_id(&route_id) { + 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}"), )); } - result.push(desktop_model_id(&route_id, supports_1m)); + result.push(InferenceModelSpec { + name: route_id.to_string(), + supports_1m, + }); } - result.sort(); - result.dedup(); + // Sort supports_1m=true first within each name so the subsequent dedup_by + // (which keeps the first occurrence) preserves the 1M-capable variant. + result.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| b.supports_1m.cmp(&a.supports_1m)) + }); + result.dedup_by(|a, b| a.name == b.name); Ok(result) } @@ -498,13 +495,13 @@ pub fn proxy_model_routes(provider: &Provider) -> Result let mut result = Vec::new(); for (route_id, route) in routes { - let supports_1m = route.supports_1m.unwrap_or(false) || has_one_m_context_suffix(route_id); - let route_id = strip_one_m_context_suffix(route_id); + 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) { + 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}"), @@ -512,8 +509,8 @@ pub fn proxy_model_routes(provider: &Provider) -> Result )); } result.push(ResolvedModelRoute { - route_id: desktop_model_id(&route_id, supports_1m), - upstream_model: upstream_model_id(upstream_model, supports_1m), + route_id: route_id.to_string(), + upstream_model: upstream_model.to_string(), supports_1m, }); } @@ -537,7 +534,7 @@ pub fn model_list_response(provider: &Provider) -> Result { let data: Vec = routes .iter() .map(|route| { - let model_id = desktop_model_id(&route.route_id, route.supports_1m); + let model_id = route.route_id.clone(); let mut item = json!({ "type": "model", "id": model_id, @@ -584,12 +581,7 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result { let credentials = direct_gateway_credentials(provider)?; - let model_ids = direct_inference_model_ids(provider)?; + let model_specs = direct_inference_model_specs(provider)?; build_gateway_profile( &credentials.base_url, &credentials.api_key, - (!model_ids.is_empty()).then_some(model_ids.as_slice()), + (!model_specs.is_empty()).then_some(model_specs.as_slice()), ) } ClaudeDesktopMode::Proxy => { let base_url = proxy_gateway_base_url_from_db(db)?; let api_key = get_or_create_gateway_token(db)?; let routes = proxy_model_routes(provider)?; - let model_ids = routes + let model_specs = routes .iter() - .map(|route| desktop_model_id(&route.route_id, route.supports_1m)) + .map(|route| InferenceModelSpec { + name: route.route_id.clone(), + supports_1m: route.supports_1m, + }) .collect::>(); - build_gateway_profile(&base_url, &api_key, Some(model_ids.as_slice())) + build_gateway_profile(&base_url, &api_key, Some(model_specs.as_slice())) } }; @@ -699,7 +694,11 @@ fn restore_official_at_paths_inner(paths: &ClaudeDesktopPaths) -> Result<(), App Ok(()) } -fn build_gateway_profile(base_url: &str, api_key: &str, model_ids: Option<&[String]>) -> Value { +fn build_gateway_profile( + base_url: &str, + api_key: &str, + model_specs: Option<&[InferenceModelSpec]>, +) -> Value { let mut profile = json!({ "disableDeploymentModeChooser": true, "inferenceGatewayApiKey": api_key, @@ -708,13 +707,9 @@ fn build_gateway_profile(base_url: &str, api_key: &str, model_ids: Option<&[Stri "inferenceProvider": "gateway" }); - if let Some(model_ids) = model_ids { - profile["inferenceModels"] = Value::Array( - model_ids - .iter() - .map(|model_id| Value::String(model_id.clone())) - .collect(), - ); + if let Some(model_specs) = model_specs { + profile["inferenceModels"] = + Value::Array(model_specs.iter().map(inference_model_json).collect()); } profile @@ -1160,7 +1155,7 @@ mod tests { ); assert_eq!( profile["inferenceModels"], - json!(["claude-deepseek-chat [1M]"]) + json!([{ "name": "claude-deepseek-chat", "supports1m": true }]) ); } @@ -1186,7 +1181,7 @@ mod tests { .starts_with("ccs-")); assert_eq!( profile["inferenceModels"], - json!(["claude-sonnet-4-6 [1M]"]) + json!([{ "name": "claude-sonnet-4-6", "supports1m": true }]) ); assert!(!profile.to_string().contains("kimi-k2")); } @@ -1219,14 +1214,15 @@ mod tests { let provider = proxy_provider("proxy"); let mapped = map_proxy_request_model( - json!({"model": "claude-sonnet-4-6 [1M]", "messages": []}), + json!({"model": "claude-sonnet-4-6", "messages": []}), &provider, ) .expect("map route"); - assert_eq!(mapped["model"], json!("kimi-k2 [1M]")); + assert_eq!(mapped["model"], json!("kimi-k2")); let models = model_list_response(&provider).expect("model list"); - assert_eq!(models["data"][0]["id"], json!("claude-sonnet-4-6 [1M]")); + assert_eq!(models["data"][0]["id"], json!("claude-sonnet-4-6")); + assert_eq!(models["data"][0]["supports1m"], json!(true)); let err = map_proxy_request_model(json!({"model": "claude-opus-4-7"}), &provider) .expect_err("unknown route should fail"); @@ -1234,29 +1230,22 @@ mod tests { } #[test] - fn claude_desktop_proxy_maps_route_without_1m_suffix() { + fn claude_desktop_proxy_rejects_1m_suffix_route() { let provider = proxy_provider("proxy"); - let mapped = map_proxy_request_model( - json!({"model": "claude-sonnet-4-6", "messages": []}), + let err = map_proxy_request_model( + json!({"model": "claude-sonnet-4-6 [1M]", "messages": []}), &provider, ) - .expect("base name should fallback-match the [1M] route"); - assert_eq!(mapped["model"], json!("kimi-k2 [1M]")); + .expect_err("1M suffix route should not be accepted"); + assert!(err.to_string().contains("claude-sonnet-4-6 [1M]")); } #[test] - fn claude_desktop_one_m_suffix_normalization_is_case_and_space_tolerant() { - assert!(is_claude_safe_model_id("claude-sonnet-4-6 [1m]")); - assert!(is_claude_safe_model_id(" claude-sonnet-4-6 [1M] ")); - assert_eq!( - strip_one_m_context_suffix(" claude-sonnet-4-6 [1m] "), - "claude-sonnet-4-6" - ); - assert_eq!( - desktop_model_id(" claude-sonnet-4-6 [1m] ", true), - "claude-sonnet-4-6 [1M]" - ); + 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-sonnet-4-6 ")); } #[test] diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index c02fb058e..be278adc6 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -111,6 +111,7 @@ type RouteRowValues = Omit; const CLAUDE_ROUTE_PREFIX = "claude-"; const ANTHROPIC_CLAUDE_ROUTE_PREFIX = "anthropic/claude-"; +const LEGACY_ONE_M_MARKER = "[1m]"; function envString( settingsConfig: Record | undefined, @@ -188,11 +189,10 @@ function initialRouteRows( function isClaudeSafeRoute(route: string) { const normalized = route.trim().toLowerCase(); - return ( - (normalized.startsWith(CLAUDE_ROUTE_PREFIX) && - normalized.length > CLAUDE_ROUTE_PREFIX.length) || - (normalized.startsWith(ANTHROPIC_CLAUDE_ROUTE_PREFIX) && - normalized.length > ANTHROPIC_CLAUDE_ROUTE_PREFIX.length) + 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, ); } @@ -825,12 +825,12 @@ export function ClaudeDesktopProviderForm({

{t("claudeDesktop.routeMapHint", { defaultValue: - "左侧决定 Claude Desktop 模型菜单里的可见模型 ID,实际写入为 claude-*;右侧填写供应商实际请求的模型名。", + "左侧决定 Claude Desktop 模型菜单里的可见模型 ID;1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。", })}

-
+
{t("claudeDesktop.routeModelLabel", { defaultValue: "Desktop 显示模型", @@ -843,7 +843,7 @@ export function ClaudeDesktopProviderForm({ {t("claudeDesktop.supports1mLabel", { - defaultValue: "1M", + defaultValue: "声明支持 1M", })} @@ -851,7 +851,7 @@ export function ClaudeDesktopProviderForm({ {routes.map((route, index) => (
@@ -896,7 +896,9 @@ export function ClaudeDesktopProviderForm({ updateRoute(index, { supports1m: checked === true }) } /> - 1M + {t("claudeDesktop.supports1mShort", { + defaultValue: "1M", + })}