From 81597fcf91689673613fa15c98bef74fb025ec31 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 18 Sep 2026 15:47:19 +0800 Subject: [PATCH] feat(claude-desktop): support custom model aliases - Add editable model lists, alias suggestions, and usage guidance - Ignore unselected rows and validate alias conflicts and naming rules - Fix alias routing through disabled or excluded providers - Preserve model routes when restoring legacy backups - Repair legacy metadata and remove unsupported context fields - Add regression tests for validation and configuration recovery --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + .../claude_models/model-catalog.json | 3 + src-tauri/src/agents/backups/restore.rs | 132 ++++--- src-tauri/src/agents/backups/tests.rs | 48 ++- src-tauri/src/agents/commands.rs | 57 ++- src-tauri/src/agents/configuration.rs | 127 +++++-- src-tauri/src/agents/discovery.rs | 2 + src-tauri/src/agents/templates.rs | 19 +- src-tauri/src/agents/templates/tests.rs | 18 + src-tauri/src/agents/transactions.rs | 7 +- src-tauri/src/core_config/aliases.rs | 334 +++++++++++++++++- src-tauri/src/main.rs | 49 ++- src-tauri/src/tests/agent_configuration.rs | 53 ++- src-tauri/src/tests/alias_save.rs | 153 +++++++- src-tauri/src/tests/desktop_alias_routing.rs | 318 ++++++++++++++++- src/i18n/ja.ts | 33 +- src/i18n/locales/en.ts | 33 +- src/i18n/locales/zh-CN.ts | 33 +- src/pages/AgentsPage.tsx | 254 +++++++++++-- src/services/agentConfigurationDraft.ts | 11 +- src/services/claudeDesktopModelRules.json | 3 + src/services/claudeDesktopModels.ts | 99 ++++++ src/styles.css | 133 +++++++ tests/agent-backups-ui.cjs | 2 +- tests/agent-management-ui.cjs | 2 +- tests/agent-pending-ui.cjs | 3 +- tests/claude-desktop-models-ui.cjs | 227 ++++++++++++ tests/claudeDesktopModels.test.ts | 110 ++++++ tests/fixtures/agent-backups.tsx | 5 + 30 files changed, 2077 insertions(+), 193 deletions(-) create mode 100644 src/services/claudeDesktopModelRules.json create mode 100644 src/services/claudeDesktopModels.ts create mode 100644 tests/claude-desktop-models-ui.cjs create mode 100644 tests/claudeDesktopModels.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e80874e..b706723 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -606,6 +606,7 @@ dependencies = [ "notify", "objc2", "objc2-app-kit", + "regex", "reqwest 0.12.28", "rusqlite", "semver", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23912d3..1642a7b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ futures-util = "0.3" getrandom = "0.3" json5 = "0.4" notify = "7" +regex = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "socks", "stream"] } rusqlite = { version = "0.32.1", features = ["bundled"] } semver = "1" diff --git a/src-tauri/resources/claude_models/model-catalog.json b/src-tauri/resources/claude_models/model-catalog.json index d543533..be17797 100644 --- a/src-tauri/resources/claude_models/model-catalog.json +++ b/src-tauri/resources/claude_models/model-catalog.json @@ -35,12 +35,15 @@ { "slug": "o3-pro" }, { "slug": "o4-mini" }, { "slug": "codex-auto-review", "context_window": 272000 }, + { "slug": "claude-fable-5-1", "context_window": 1000000, "claude_code_effort_level": "high" }, + { "slug": "claude-fable-5", "context_window": 1000000, "claude_code_effort_level": "high" }, { "slug": "claude-opus-5" }, { "slug": "claude-opus-4-8" }, { "slug": "claude-opus-4-7" }, { "slug": "claude-opus-4-6" }, { "slug": "claude-opus-4-5" }, { "slug": "claude-opus-4-1" }, + { "slug": "claude-sonnet-5" }, { "slug": "claude-sonnet-4-6" }, { "slug": "claude-sonnet-4-5" }, { "slug": "claude-sonnet-4" }, diff --git a/src-tauri/src/agents/backups/restore.rs b/src-tauri/src/agents/backups/restore.rs index 0039959..a2729df 100644 --- a/src-tauri/src/agents/backups/restore.rs +++ b/src-tauri/src/agents/backups/restore.rs @@ -33,11 +33,16 @@ fn local_restore_plan(client: &str, home: &Path, id: &str) -> Result Result>, String> { +fn desktop_restore_configuration( + plan: &RestorePlan, +) -> Result)>, String> { if plan.version.client != "claude-desktop" { return Ok(None); } - if !desktop_profile_needs_mapping(&plan.after)? { + if !desktop_profile_needs_mapping(&plan.after)? + && plan.version.mappings.as_ref() + .and_then(|m| m.desktop_models.as_ref()).is_none() + { return Ok(None); } let (path, bytes) = &plan.after[2]; @@ -49,30 +54,56 @@ fn desktop_restore_models(plan: &RestorePlan) -> Result>(); - let routes = [ - CLAUDE_DESKTOP_OPUS_MODEL_ID, - CLAUDE_DESKTOP_SONNET_MODEL_ID, - CLAUDE_DESKTOP_HAIKU_MODEL_ID, - ]; - let mappings = - plan.version.mappings.as_ref().ok_or( + let mut mappings = plan.version.mappings.as_ref().ok_or( "此备份版本缺少 Claude Desktop 模型映射,无法安全恢复内核路由,请重新配置模型", - )?; - Ok(Some( - routes - .into_iter() - .zip([&mappings.opus, &mappings.sonnet, &mappings.haiku]) - .map(|(route, source)| AgentModelOption { - input_modalities: None, - harness_metadata: None, - name: source.clone(), - alias: None, - is_alias: source != route - && names.iter().any(|name| name.eq_ignore_ascii_case(source)), - context_window: None, - }) - .collect(), - )) + )?.clone(); + if mappings.desktop_models.is_none() { + let roles = [ + (CLAUDE_DESKTOP_OPUS_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0], &mappings.opus, mappings.opus_1m), + (CLAUDE_DESKTOP_SONNET_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1], &mappings.sonnet, mappings.sonnet_1m), + (CLAUDE_DESKTOP_HAIKU_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2], &mappings.haiku, mappings.haiku_1m), + ]; + let mut entries = Vec::::new(); + for id in names { + let Some((_, _, source, context_1m)) = roles.iter() + .find(|(_, _, source, _)| source.eq_ignore_ascii_case(id)) + .or_else(|| roles.iter().find(|(current, legacy, _, _)| { + current.eq_ignore_ascii_case(id) || legacy.eq_ignore_ascii_case(id) + })) + else { + return Err("此备份中的模型 ID 缺少对应来源,无法安全恢复内核路由".into()); + }; + if let Some(existing) = entries.iter_mut() + .find(|entry| entry.model_id().eq_ignore_ascii_case(id)) + { + existing.context_1m |= *context_1m; + } else { + entries.push(ClaudeDesktopModelMapping { + model: source.to_string(), + alias: if source.eq_ignore_ascii_case(id) { + String::new() + } else { + id.into() + }, + context_1m: *context_1m, + }); + } + } + mappings.desktop_models = Some(entries); + } + let entries = mappings.desktop_models.as_ref().ok_or("缺少备份模型映射")?; + validate_claude_desktop_entries(entries)?; + let models = entries.iter() + .map(|entry| AgentModelOption { + name: entry.source_or_alias().to_string(), + alias: None, + is_alias: false, + context_window: None, + input_modalities: None, + harness_metadata: None, + }) + .collect(); + Ok(Some((mappings, models))) } fn attach_core_restore( @@ -84,14 +115,31 @@ fn attach_core_restore( serde_norway::from_str::(&before).map_err(|e| e.to_string())?; let target = serde_norway::from_str::(&after).map_err(|e| e.to_string())?; - for route in [ + let mut routes = vec![ CLAUDE_DESKTOP_OPUS_MODEL_ID, CLAUDE_DESKTOP_SONNET_MODEL_ID, CLAUDE_DESKTOP_HAIKU_MODEL_ID, - ] { + LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0], + LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1], + LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2], + ] + .into_iter() + .map(str::to_string) + .collect::>(); + for root in [&original, &target] + .into_iter() + .filter_map(serde_norway::Value::as_mapping) + { + for alias in managed_claude_desktop_aliases(root) { + if !routes.contains(&alias) { + routes.push(alias); + } + } + } + for route in routes { let source = |root: &serde_norway::Value| { root.as_mapping() - .and_then(|root| configured_model_client_identity(root, route)) + .and_then(|root| configured_model_client_identity(root, &route)) .map(|(source, _)| source) }; let old = source(&original); @@ -125,18 +173,22 @@ pub(crate) async fn prepare_restore_plan( id: &str, ) -> Result { let mut plan = local_restore_plan(client, home, id)?; - if let Some(models) = desktop_restore_models(&plan)? { - let mappings = plan.version.mappings.as_ref().ok_or("缺少备份模型映射")?; + if let Some((mappings, models)) = desktop_restore_configuration(&plan)? { let before = fetch_management_config_yaml(config) .await .map_err(agent_core_error)?; - let after = match ensure_claude_desktop_model_aliases_in_yaml(&before, mappings, &models) { + let after = match ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + &before, + &mappings, + &models, + &[], + ) { Ok(after) => after, Err(_) => { let definitions = fetch_oauth_model_definitions(config).await; ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( &before, - mappings, + &mappings, &models, &definitions, ) @@ -226,15 +278,13 @@ pub(super) fn desktop_profile_needs_mapping(images: &Images) -> Result>(); - let routes = [ - CLAUDE_DESKTOP_OPUS_MODEL_ID, - CLAUDE_DESKTOP_SONNET_MODEL_ID, - CLAUDE_DESKTOP_HAIKU_MODEL_ID, - ]; - if !names - .iter() - .any(|name| routes.iter().any(|route| route.eq_ignore_ascii_case(name))) - { + let labeled_mapping = profile.get("inferenceModels").and_then(Value::as_array) + .into_iter().flatten().any(|entry| { + let name = entry.get("name").and_then(Value::as_str); + let label = entry.get("labelOverride").and_then(Value::as_str); + matches!((name, label), (Some(name), Some(label)) if name != label) + }); + if !labeled_mapping && !names.iter().any(|name| is_claude_desktop_route_id(name)) { return Ok(false); } Ok(true) diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index 92d98b2..4ed651e 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -798,6 +798,50 @@ fn pi_template_only_writes_configuration_and_package_references() { assert_eq!(list_backups("pi", &home.0).unwrap().versions.len(), 1); } +#[test] +fn desktop_setup_repairs_legacy_names_without_changing_other_profiles() { + if !AgentClient::ClaudeDesktop.supported_platform() { + return; + } + let home = Home::new(); + let paths = config_paths("claude-desktop", &home.0).unwrap(); + let legacy_id = "fbd7eef8-4705-47c7-a5a7-a44b2952afe1"; + let named_id = "d186251a-0529-4dc7-bab8-81f391d975ce"; + let initial = serde_json::json!({ + "appliedId": legacy_id, + "custom": {"keep": true}, + "entries": [ + {"id": legacy_id, "custom": [1, 2]}, + {"id": named_id, "name": "My gateway", "custom": true} + ] + }); + save(&paths[3], initial.to_string()); + let other_profile = paths[3].parent().unwrap().join(format!("{legacy_id}.json")); + let other_content = "{\"inferenceGatewayBaseUrl\":\"https://example.test\"}\n"; + save(&other_profile, other_content); + + apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap(); + let repaired: Value = serde_json::from_slice(&fs::read(&paths[3]).unwrap()).unwrap(); + assert!(repaired["entries"].as_array().unwrap().iter().all(|entry| { + entry["id"].is_string() && entry["name"].is_string() + })); + assert_eq!(repaired["entries"][0]["name"], format!("Configuration {legacy_id}")); + assert_eq!(repaired["entries"][0]["custom"], initial["entries"][0]["custom"]); + assert_eq!(repaired["entries"][1], initial["entries"][1]); + assert_eq!(repaired["custom"], initial["custom"]); + assert_eq!(fs::read_to_string(other_profile).unwrap(), other_content); + + let before = config_images(&paths).unwrap(); + apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap(); + assert_eq!(before, config_images(&paths).unwrap()); + for index in [0, 1] { + let mut changed = repaired.clone(); + changed["entries"][index]["name"] = serde_json::json!("Unexpected rename"); + let updates = vec![AgentFileUpdate { path: paths[3].clone(), after: changed.to_string() }]; + assert!(prepare_config_updates("claude-desktop", &paths, &before, &updates, false).is_err()); + } +} + #[test] fn desktop_current_mapping_survives_backup_deletion_and_rejects_unreliable_profile() { if !AgentClient::ClaudeDesktop.supported_platform() { @@ -813,7 +857,7 @@ fn desktop_current_mapping_survives_backup_deletion_and_rejects_unreliable_profi .0 .revision; apply(&home.0, AgentClient::ClaudeDesktop, "gpt-two").unwrap(); - assert_eq!(original, config_images(&paths).unwrap()); + assert_ne!(original, config_images(&paths).unwrap()); assert_ne!( revision, preview("claude-desktop", &paths, &backup.id) @@ -857,6 +901,7 @@ fn desktop_model_extensions_do_not_resurrect_disabled_one_million_context_flags( .unwrap(); let mut value: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); value["inferenceModels"][0]["custom"] = serde_json::json!({"keep":true}); + value["inferenceModels"][0]["contextWindow"] = serde_json::json!(1_000_000); save(&paths[2], value.to_string()); let next_mappings = ClaudeDesktopModelMappings::all("gpt-one"); apply_agent_configuration_with_oauth( @@ -870,6 +915,7 @@ fn desktop_model_extensions_do_not_resurrect_disabled_one_million_context_flags( .unwrap(); let value: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); assert_eq!(value["inferenceModels"][0]["custom"]["keep"], true); + assert!(value["inferenceModels"][0].get("contextWindow").is_none()); assert!(value["inferenceModels"][0].get("supports1m").is_none()); assert!(value["inferenceModels"][0].get("prefer1m").is_none()); } diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 1393597..9bd5014 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -960,6 +960,27 @@ pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> boo ) } +fn resolve_claude_desktop_source_model(models: &[AgentModelOption], value: &str) -> Result { + let model = validate_agent_model(value)?; + Ok(models.iter().find(|entry| entry.name.eq_ignore_ascii_case(&model)) + .map(|entry| entry.name.clone()).unwrap_or(model)) +} + +pub(crate) fn resolve_agent_configuration_model( + client: AgentClient, + models: &[AgentModelOption], + model: &str, + desktop_mappings: Option<&ClaudeDesktopModelMappings>, +) -> Result { + if client == AgentClient::ClaudeDesktop { + if let Some(entries) = desktop_mappings.and_then(|mappings| mappings.desktop_models.as_ref()) { + validate_claude_desktop_entries(entries)?; + return resolve_claude_desktop_source_model(models, entries[0].source_or_alias()); + } + } + resolve_available_agent_model(models, &validate_agent_model(model)?) +} + pub(crate) fn resolve_claude_desktop_model_mappings( client: AgentClient, models: &[AgentModelOption], @@ -969,10 +990,26 @@ pub(crate) fn resolve_claude_desktop_model_mappings( if client != AgentClient::ClaudeDesktop { return Ok(None); } - let requested = requested.ok_or("请重新选择 Claude Desktop 的模型映射")?; + let mut requested = requested.ok_or("请重新配置 Claude Desktop 的模型与别名")?; let resolve = |model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?); + if let Some(entries) = requested.desktop_models.as_mut() { + validate_claude_desktop_entries(entries)?; + for entry in entries.iter_mut() { + entry.model = resolve_claude_desktop_source_model(models, entry.source_or_alias())?; + entry.alias = if entry.alias.trim().eq_ignore_ascii_case(&entry.model) { + String::new() + } else { + entry.alias.trim().to_string() + }; + } + requested.sonnet = entries[0].source_or_alias().to_string(); + requested.opus.clear(); + requested.haiku.clear(); + return Ok(Some(requested)); + } Ok(Some(ClaudeDesktopModelMappings { + desktop_models: None, opus: resolve(&requested.opus)?, sonnet: resolve(&requested.sonnet)?, haiku: resolve(&requested.haiku)?, @@ -1009,6 +1046,7 @@ pub(crate) fn resolve_claude_code_model_mappings( let resolve = |model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?); Ok(Some(ClaudeDesktopModelMappings { + desktop_models: None, opus: resolve(&requested.opus)?, sonnet: resolve(&requested.sonnet)?, haiku: resolve(&requested.haiku)?, @@ -1061,7 +1099,9 @@ pub(crate) async fn apply_agent_config( } validate_agent_can_enable(client, &home, config.port, api_key)?; let prepared = fetch_prepared_agent_models(client, &config).await?; - let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; + let model = resolve_agent_configuration_model( + client, &prepared.models, &model, claude_desktop_model_mappings.as_ref(), + )?; let claude_code_model_mappings = resolve_claude_code_model_mappings( client, &prepared.models, @@ -1130,8 +1170,9 @@ pub(crate) async fn set_agent_config_enabled( let api_key = effective_agent_api_key(&config); validate_agent_can_enable(client, &home, port, api_key)?; let prepared = fetch_prepared_agent_models(client, &config).await?; - let model = - resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; + let model = resolve_agent_configuration_model( + client, &prepared.models, &model, claude_desktop_model_mappings.as_ref(), + )?; let claude_code_model_mappings = resolve_claude_code_model_mappings( client, &prepared.models, @@ -1380,7 +1421,13 @@ pub(crate) fn mark_configured_agent_model_aliases( let configured_models = configured_models .as_sequence() .ok_or_else(|| format!("{section}.models 必须是数组"))?; - mark_agent_model_aliases_from_sequence(models, configured_models); + for configured in configured_models { + if configured_model_identity(configured).is_some_and(|(_, client_model, _)| { + configured_provider_model_is_enabled(provider, &client_model) + }) { + mark_agent_model_aliases_from_sequence(models, std::slice::from_ref(configured)); + } + } } } diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 741f952..3b37f7a 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -779,26 +779,48 @@ pub(crate) fn build_claude_desktop_profile( let mappings = mappings .cloned() .unwrap_or_else(|| ClaudeDesktopModelMappings::all(model)); - let inference_models = vec![ - claude_desktop_inference_model( - CLAUDE_DESKTOP_OPUS_MODEL_ID, - &mappings.opus, - mappings.opus_1m, - models, - ), - claude_desktop_inference_model( - CLAUDE_DESKTOP_SONNET_MODEL_ID, - &mappings.sonnet, - mappings.sonnet_1m, - models, - ), - claude_desktop_inference_model( - CLAUDE_DESKTOP_HAIKU_MODEL_ID, - &mappings.haiku, - mappings.haiku_1m, - models, - ), - ]; + let inference_models = if let Some(entries) = &mappings.desktop_models { + validate_claude_desktop_entries(entries)?; + let mut families = HashSet::new(); + entries + .iter() + .map(|entry| { + let mut value = claude_desktop_inference_model( + entry.model_id(), + entry.source_or_alias(), + entry.context_1m, + models, + ); + value["name"] = serde_json::json!(entry.model_id()); + value["labelOverride"] = serde_json::json!(entry.source_or_alias()); + if let Some(tier) = claude_desktop_family_tier(entry.model_id()) { + value["isFamilyDefault"] = serde_json::json!(families.insert(tier)); + } + value + }) + .collect() + } else { + vec![ + claude_desktop_inference_model( + CLAUDE_DESKTOP_OPUS_MODEL_ID, + &mappings.opus, + mappings.opus_1m, + models, + ), + claude_desktop_inference_model( + CLAUDE_DESKTOP_SONNET_MODEL_ID, + &mappings.sonnet, + mappings.sonnet_1m, + models, + ), + claude_desktop_inference_model( + CLAUDE_DESKTOP_HAIKU_MODEL_ID, + &mappings.haiku, + mappings.haiku_1m, + models, + ), + ] + }; let mut deduplicated_models = Vec::::with_capacity(inference_models.len()); for entry in inference_models { let Some(name) = entry.get("name").and_then(serde_json::Value::as_str) else { @@ -831,35 +853,55 @@ pub(crate) fn claude_desktop_inference_model( enable_1m: bool, models: &[AgentModelOption], ) -> serde_json::Value { - let selected = models - .iter() - .find(|model| model.name.eq_ignore_ascii_case(source_model)); - let direct_alias = selected.is_some_and(|model| model.is_alias); - let context_window = claude_effective_context_window(models, source_model, enable_1m); + let use_source = claude_desktop_uses_source_directly(source_model, models); let mut entry = serde_json::Map::new(); entry.insert( "name".to_string(), - serde_json::json!(if direct_alias { + serde_json::json!(if use_source { source_model } else { route_model }), ); - if let Some(context_window) = context_window { - entry.insert( - "contextWindow".to_string(), - serde_json::json!(context_window), - ); - if enable_1m { - entry.insert("supports1m".to_string(), serde_json::json!(true)); - entry.insert("prefer1m".to_string(), serde_json::json!(true)); - } + if !use_source { + entry.insert("labelOverride".to_string(), serde_json::json!(source_model)); + } + if let Some(tier) = claude_desktop_family_tier(route_model) { + entry.insert("anthropicFamilyTier".to_string(), serde_json::json!(tier)); + entry.insert("isFamilyDefault".to_string(), serde_json::json!(true)); + } + if enable_1m { + entry.insert("supports1m".to_string(), serde_json::json!(true)); + entry.insert("prefer1m".to_string(), serde_json::json!(true)); } serde_json::Value::Object(entry) } +pub(crate) fn claude_desktop_family_tier(route_model: &str) -> Option<&'static str> { + let lowered = route_model.to_ascii_lowercase(); + if lowered.starts_with("claude-opus-") || lowered.contains("-opus-") + || route_model.eq_ignore_ascii_case(CLAUDE_DESKTOP_OPUS_MODEL_ID) + || route_model.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]) + { + Some("opus") + } else if lowered.starts_with("claude-sonnet-") || lowered.contains("-sonnet-") + || route_model.eq_ignore_ascii_case(CLAUDE_DESKTOP_SONNET_MODEL_ID) + || route_model.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1]) + { + Some("sonnet") + } else if lowered.starts_with("claude-haiku-") || lowered.contains("-haiku-") + || route_model.eq_ignore_ascii_case(CLAUDE_DESKTOP_HAIKU_MODEL_ID) + || route_model.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2]) + { + Some("haiku") + } else { + None + } +} + pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 配置索引")?; + repair_claude_desktop_meta_names(&mut root); let entries = ensure_json_array_entry(&mut root, "entries"); let mut managed_entry = None; let mut retained_entries = Vec::with_capacity(entries.len()); @@ -889,6 +931,21 @@ pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result) { + let Some(entries) = root.get_mut("entries").and_then(serde_json::Value::as_array_mut) else { + return; + }; + for entry in entries { + let Some(id) = entry.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + if entry.get("name").and_then(serde_json::Value::as_str).is_none() { + let name = format!("Configuration {id}"); + entry["name"] = serde_json::json!(name); + } + } +} + pub(crate) fn clear_agent_managed_configuration( client: AgentClient, home: &Path, diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 5bd2ab8..a4fbf38 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -2801,6 +2801,7 @@ pub(crate) fn inspect_claude_code_model_mappings( .and_then(serde_json::Value::as_str) .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); Ok(Some(ClaudeDesktopModelMappings { + desktop_models: None, opus, sonnet, haiku, @@ -2926,6 +2927,7 @@ pub(crate) fn claude_code_model_settings( mappings: &ClaudeDesktopModelMappings, ) -> ClaudeDesktopModelMappings { ClaudeDesktopModelMappings { + desktop_models: None, opus: claude_code_model_setting(&mappings.opus, mappings.opus_1m), sonnet: claude_code_model_setting(&mappings.sonnet, mappings.sonnet_1m), haiku: claude_code_model_setting(&mappings.haiku, mappings.haiku_1m), diff --git a/src-tauri/src/agents/templates.rs b/src-tauri/src/agents/templates.rs index 803eac3..8c54ec3 100644 --- a/src-tauri/src/agents/templates.rs +++ b/src-tauri/src/agents/templates.rs @@ -92,6 +92,21 @@ pub(crate) fn agent_core_error(error: String) -> String { "内核别名或智能体配置写入失败,已恢复原配置,请检查连接和模型映射后重试".into() } else if error.contains("配置已变化") { "内核配置已变化,请重新预览后重试".into() + } else if error.contains("已被其他模型使用") { + "模型别名已被其他模型占用,请切换其他别名后重试".into() + } else if error.contains("无法确定模型") && error.contains("CPA 配置来源") { + "无法找到原模型的有效接入来源,请确认接入已启用且未屏蔽原模型或别名,刷新模型列表后重试".into() + } else if error.starts_with("更新后的内核配置与预期值不一致") + || error.starts_with("验证更新后的内核配置失败") + { + "内核配置格式兼容性校验失败,已取消别名同步,原配置未写入".into() + } else if error.starts_with("解析内核 YAML 配置失败") { + "内核 YAML 配置格式无效,请检查配置格式后重试".into() + } else if error.starts_with("管理 API 错误 (401)") + || error.starts_with("管理 API 错误 (403)") + || error.starts_with("管理接口不可用") + { + "内核管理接口认证失败,请检查管理密钥后重试".into() } else { "内核模型别名同步失败,请检查内核连接和模型映射".into() } @@ -137,7 +152,9 @@ async fn prepare_template_plan( } else { let parsed = AgentClient::parse(client)?; let prepared = fetch_prepared_agent_models(parsed, config).await?; - let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(model)?)?; + let model = resolve_agent_configuration_model( + parsed, &prepared.models, model, claude_desktop_model_mappings.as_ref(), + )?; let code_mappings = resolve_claude_code_model_mappings( parsed, &prepared.models, diff --git a/src-tauri/src/agents/templates/tests.rs b/src-tauri/src/agents/templates/tests.rs index 4d5d94f..56d9c0d 100644 --- a/src-tauri/src/agents/templates/tests.rs +++ b/src-tauri/src/agents/templates/tests.rs @@ -16,6 +16,24 @@ fn core_failure_messages_expose_outcomes_without_secret_source_text() { } } +#[test] +fn core_failure_messages_distinguish_alias_and_configuration_failures() { + for (detail, expected) in [ + ("别名 claude-sonnet-5 已被其他模型使用,请更换别名 secret-token", "请切换其他别名"), + ("无法确定模型 private-model 的 CPA 配置来源,无法创建 Claude Desktop 别名 secret-token", "有效接入来源"), + ("更新后的内核配置与预期值不一致(路径: secret-token),已拒绝写入", "格式兼容性校验失败"), + ("验证更新后的内核配置失败: secret-token", "原配置未写入"), + ("解析内核 YAML 配置失败: secret-token", "YAML 配置格式无效"), + ("管理 API 错误 (401): secret-token", "认证失败"), + ("管理 API 错误 (403): secret-token", "认证失败"), + ] { + let rendered = agent_core_error(detail.into()); + assert!(rendered.contains(expected), "{rendered}"); + assert!(!rendered.contains("secret-token")); + assert!(!rendered.contains("private-model")); + } +} + #[tokio::test] async fn template_confirmation_is_bound_to_files_and_generated_content() { let home = std::env::temp_dir().join(format!( diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index d2e715d..9fe43b1 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -389,7 +389,7 @@ fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: & fn merge_entry(client: &str, before: &Value, after: &mut Value) { let mut extensions = before.clone(); let owned: &[&str] = match client { - "claude-desktop" => &["name", "contextWindow", "supports1m", "prefer1m"], + "claude-desktop" => &["name", "labelOverride", "anthropicFamilyTier", "isFamilyDefault", "contextWindow", "supports1m", "prefer1m"], "opencode" | "zcode" => &["name"], "openclaw" => &["id", "name", "alias"], "deepseek-harness" => &["id", "name", "contextWindow", "input", "maxTokens", "reasoningEfforts", "compat"], @@ -549,6 +549,11 @@ fn validate_unmanaged_preserved( ) -> Result<(), String> { let project = |value: &Value| -> Result { let mut value = value.clone(); + if client == "claude-desktop" && paths.get(3).is_some_and(|p| p == path) { + if let Some(root) = value.as_object_mut() { + repair_claude_desktop_meta_names(root); + } + } if client == "claude-code" { if let Some(env) = value.get_mut("env").and_then(Value::as_object_mut) { env.retain(|key, _| { diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 0c8d9f1..a3edbb6 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -341,6 +341,26 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml mappings: &ClaudeDesktopModelMappings, models: &[AgentModelOption], oauth_model_definitions: &[OAuthModelDefinitions], +) -> Result { + ensure_claude_desktop_model_aliases_with_oauth_definitions_and_routes_in_yaml( + content, + mappings, + models, + oauth_model_definitions, + [ + CLAUDE_DESKTOP_OPUS_MODEL_ID, + CLAUDE_DESKTOP_SONNET_MODEL_ID, + CLAUDE_DESKTOP_HAIKU_MODEL_ID, + ], + ) +} + +pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_and_routes_in_yaml( + content: &str, + mappings: &ClaudeDesktopModelMappings, + models: &[AgentModelOption], + oauth_model_definitions: &[OAuthModelDefinitions], + routes: [&str; 3], ) -> Result { let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; @@ -350,11 +370,76 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; let sources = root.clone(); + if let Some(entries) = &mappings.desktop_models { + validate_claude_desktop_entries(entries)?; + let managed_aliases = managed_claude_desktop_aliases(&sources); + for entry in entries { + if !entry.has_mapping() { + continue; + } + let occupied = configured_model_client_identity(&sources, &entry.alias).is_some() + || models + .iter() + .any(|model| model.name.eq_ignore_ascii_case(&entry.alias)); + let unmanaged_collision = claude_desktop_configured_models(&sources) + .into_iter() + .any(|model| { + configured_model_identity(model).is_some_and(|(_, alias, _)| { + alias.eq_ignore_ascii_case(&entry.alias) + && !is_managed_claude_model_alias(model, &alias) + }) + }); + if unmanaged_collision + || (occupied && !managed_aliases.iter() + .any(|alias| alias.eq_ignore_ascii_case(&entry.alias))) + { + return Err(format!("别名 {} 已被其他模型使用,请更换别名", entry.alias)); + } + } + for alias in managed_aliases { + if !entries.iter().any(|entry| entry.source_or_alias().eq_ignore_ascii_case(&alias)) { + remove_managed_claude_model_alias(root, &alias)?; + } + } + for entry in entries { + if entry.has_mapping() { + ensure_claude_desktop_model_alias( + root, + &sources, + &entry.model, + &entry.alias, + oauth_model_definitions, + )?; + } + } + if *root == sources { + return Ok(content.to_string()); + } + return render_updated_core_yaml(&mut document, updated); + } + for alias in managed_claude_desktop_aliases(&sources) { + if !is_claude_desktop_route_id(&alias) + && ![&mappings.opus, &mappings.sonnet, &mappings.haiku] + .iter() + .any(|model| model.eq_ignore_ascii_case(&alias)) + { + remove_managed_claude_model_alias(root, &alias)?; + } + } for (alias, source_model) in [ - (CLAUDE_DESKTOP_OPUS_MODEL_ID, mappings.opus.as_str()), - (CLAUDE_DESKTOP_SONNET_MODEL_ID, mappings.sonnet.as_str()), - (CLAUDE_DESKTOP_HAIKU_MODEL_ID, mappings.haiku.as_str()), + (routes[0], mappings.opus.as_str()), + (routes[1], mappings.sonnet.as_str()), + (routes[2], mappings.haiku.as_str()), ] { + if let Some(paired_alias) = paired_claude_desktop_alias(alias) { + remove_managed_claude_model_alias(root, paired_alias)?; + } + if claude_desktop_uses_source_directly(source_model, models) { + if !source_model.eq_ignore_ascii_case(alias) { + remove_managed_claude_model_alias(root, alias)?; + } + continue; + } let direct_alias = models .iter() .any(|model| model.name.eq_ignore_ascii_case(source_model) && model.is_alias); @@ -372,9 +457,102 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml )?; } } + if *root == sources { + return Ok(content.to_string()); + } render_updated_core_yaml(&mut document, updated) } +pub(crate) fn validate_claude_desktop_entries( + entries: &[ClaudeDesktopModelMapping], +) -> Result<(), String> { + if entries.is_empty() { + return Err("请至少添加一个 Claude Desktop 模型".into()); + } + let mut ids = HashSet::new(); + for entry in entries { + validate_agent_model(entry.source_or_alias())?; + if entry.source_or_alias().chars().any(char::is_whitespace) { + return Err("原模型 ID 不能包含空白字符".into()); + } + let alias = entry.alias.trim(); + if !alias.is_empty() && !alias.eq_ignore_ascii_case(entry.source_or_alias()) && !valid_claude_desktop_alias(alias) { + return Err(format!("Claude Desktop 别名 {alias} 必须以 claude- 开头,只能包含小写字母、数字、连字符和小数点,且不能包含 gpt、grok、gemini、deepseek 等其他模型系列名称")); + } + let id = entry.model_id(); + if !ids.insert(id.to_ascii_lowercase()) { + return Err(format!("Claude Desktop 模型 ID 重复: {id}")); + } + } + Ok(()) +} + +pub(crate) fn valid_claude_desktop_alias(alias: &str) -> bool { + claude_desktop_alias_has_valid_syntax(alias) && !claude_desktop_has_other_model_family(alias) +} + +fn claude_desktop_alias_has_valid_syntax(alias: &str) -> bool { + alias.len() <= 128 + && alias.strip_prefix("claude-").is_some_and(|rest| { + !rest.is_empty() + && rest.split(['-', '.']).all(|part| { + !part.is_empty() + && part.bytes().all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) + }) + }) +} + +fn claude_desktop_has_other_model_family(name: &str) -> bool { + static OTHER_MODEL_FAMILY: LazyLock = LazyLock::new(|| { + let rules: serde_json::Value = serde_json::from_str(include_str!( + "../../../src/services/claudeDesktopModelRules.json" + )) + .expect("valid bundled Claude Desktop model rules"); + regex::RegexBuilder::new( + rules["otherModelFamilyPattern"] + .as_str() + .expect("Claude Desktop model-family pattern"), + ) + .unicode(false) + .build() + .expect("valid Claude Desktop model-family pattern") + }); + OTHER_MODEL_FAMILY.is_match(&name.to_ascii_lowercase()) +} + +fn claude_desktop_configured_models(root: &serde_norway::Mapping) -> Vec<&serde_norway::Value> { + let mut models = Vec::new(); + for section in MODEL_ALIAS_CONFIG_SECTIONS { + for provider in yaml_mapping_value(root, section) + .and_then(serde_norway::Value::as_sequence) + .into_iter() + .flatten() + { + if let Some(entries) = provider.as_mapping() + .and_then(|provider| yaml_mapping_value(provider, "models")) + .and_then(serde_norway::Value::as_sequence) + { + models.extend(entries); + } + } + } + if let Some(channels) = yaml_mapping_value(root, "oauth-model-alias") + .and_then(serde_norway::Value::as_mapping) + { + for entries in channels.values().filter_map(serde_norway::Value::as_sequence) { + models.extend(entries); + } + } + models +} + +pub(crate) fn managed_claude_desktop_aliases(root: &serde_norway::Mapping) -> Vec { + claude_desktop_configured_models(root).into_iter().filter_map(|model| { + let (_, alias, _) = configured_model_identity(model)?; + is_managed_claude_model_alias(model, &alias).then_some(alias) + }).collect::>().into_iter().collect() +} + #[cfg(test)] pub(crate) fn remove_managed_claude_model_aliases_in_yaml(content: &str) -> Result { let mut document = yaml_serde_edit::YamlValue::parse(content) @@ -388,6 +566,9 @@ pub(crate) fn remove_managed_claude_model_aliases_in_yaml(content: &str) -> Resu CLAUDE_DESKTOP_OPUS_MODEL_ID, CLAUDE_DESKTOP_SONNET_MODEL_ID, CLAUDE_DESKTOP_HAIKU_MODEL_ID, + LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0], + LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1], + LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2], ] { changed |= remove_managed_claude_model_alias(root, alias)?; } @@ -451,10 +632,7 @@ pub(crate) fn remove_existing_claude_model_alias( .as_sequence_mut() .ok_or_else(|| format!("{section}.models 必须是数组"))?; let before = models.len(); - models.retain(|model| { - configured_model_identity(model) - .is_none_or(|(_, client_model, _)| !client_model.eq_ignore_ascii_case(alias)) - }); + models.retain(|model| !occupies_claude_client_alias(model, alias)); changed |= models.len() != before; } } @@ -485,8 +663,7 @@ pub(crate) fn remove_oauth_claude_model_alias( if managed_only { !is_managed_claude_model_alias(entry, alias) } else { - configured_model_identity(entry) - .is_none_or(|(_, client_model, _)| !client_model.eq_ignore_ascii_case(alias)) + !occupies_claude_client_alias(entry, alias) } }); if entries.len() != before { @@ -507,17 +684,94 @@ pub(crate) fn remove_oauth_claude_model_alias( } pub(crate) fn managed_claude_alias_display_name(alias: &str) -> Option<&'static str> { - if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_OPUS_MODEL_ID) { + if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_OPUS_MODEL_ID) + || alias.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]) + { Some(MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME) - } else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_SONNET_MODEL_ID) { + } else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_SONNET_MODEL_ID) + || alias.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1]) + { Some(MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME) - } else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_HAIKU_MODEL_ID) { + } else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_HAIKU_MODEL_ID) + || alias.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2]) + { Some(MANAGED_CLAUDE_HAIKU_ALIAS_DISPLAY_NAME) + } else if claude_desktop_alias_has_valid_syntax(alias) { + Some("EasyCLIProxyAPI managed Claude Desktop mapping") } else { None } } +pub(crate) fn claude_desktop_uses_source_directly( + source_model: &str, + models: &[AgentModelOption], +) -> bool { + models + .iter() + .any(|model| model.name.eq_ignore_ascii_case(source_model) && model.is_alias) + || is_claude_native_model_id(source_model) +} + +pub(crate) fn is_claude_native_model_id(name: &str) -> bool { + let name = name.trim(); + if name.is_empty() || claude_desktop_has_other_model_family(name) { + return false; + } + let lowered = name.to_ascii_lowercase(); + if lowered.starts_with("claude-") + || lowered.starts_with("anthropic.") + || lowered.contains("anthropic/") + { + return true; + } + ["opus", "sonnet", "haiku", "fable", "mythos"] + .into_iter() + .any(|family| { + lowered == family + || lowered + .strip_prefix(&format!("{family}-")) + .is_some_and(|rest| { + !rest.is_empty() + && rest + .chars() + .all(|ch| ch.is_ascii_digit() || ch == '.') + }) + }) +} + +pub(crate) fn is_claude_desktop_route_id(name: &str) -> bool { + claude_desktop_route_pairs() + .iter() + .any(|(current, legacy)| current.eq_ignore_ascii_case(name) || legacy.eq_ignore_ascii_case(name)) +} + +fn claude_desktop_route_pairs() -> [(&'static str, &'static str); 3] { + [ + (CLAUDE_DESKTOP_OPUS_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]), + (CLAUDE_DESKTOP_SONNET_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1]), + (CLAUDE_DESKTOP_HAIKU_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2]), + ] +} + +fn paired_claude_desktop_alias(alias: &str) -> Option<&'static str> { + claude_desktop_route_pairs().into_iter().find_map(|(current, legacy)| { + if alias.eq_ignore_ascii_case(current) { + Some(legacy) + } else if alias.eq_ignore_ascii_case(legacy) { + Some(current) + } else { + None + } + }) +} + +fn occupies_claude_client_alias(model: &serde_norway::Value, alias: &str) -> bool { + configured_model_identity(model).is_some_and(|(source, client_model, _)| { + client_model.eq_ignore_ascii_case(alias) && !source.eq_ignore_ascii_case(&client_model) + }) +} + pub(crate) fn is_managed_claude_model_alias(model: &serde_norway::Value, alias: &str) -> bool { let Some(expected_display_name) = managed_claude_alias_display_name(alias) else { return false; @@ -547,9 +801,7 @@ pub(crate) fn configured_managed_claude_alias_matches( .into_iter() .flatten() .filter_map(serde_norway::Value::as_mapping) - .filter(|provider| { - yaml_mapping_value(provider, "disabled") != Some(&serde_norway::Value::Bool(true)) - }) + .filter(|provider| configured_provider_model_is_enabled(provider, alias)) .filter_map(|provider| yaml_mapping_value(provider, "models")) .filter_map(serde_norway::Value::as_sequence) .flatten() @@ -575,7 +827,7 @@ pub(crate) fn ensure_claude_desktop_model_alias( if configured_managed_claude_alias_matches(root, alias, source_model) { return Ok(()); } - let source = resolve_claude_desktop_alias_source(sources, source_model)?; + let source = resolve_claude_desktop_alias_source(sources, source_model, alias)?; if let Some(source) = source { remove_existing_claude_model_alias(root, alias)?; append_claude_desktop_model_alias(root, source, alias)?; @@ -665,9 +917,49 @@ enum ClaudeDesktopAliasSource { }, } +pub(crate) fn configured_provider_model_is_enabled(provider: &serde_norway::Mapping, model: &str) -> bool { + if yaml_mapping_value(provider, "disabled") == Some(&serde_norway::Value::Bool(true)) { + return false; + } + let model = model.trim().to_lowercase(); + !yaml_mapping_value(provider, "excluded-models") + .and_then(serde_norway::Value::as_sequence) + .into_iter() + .flatten() + .filter_map(serde_norway::Value::as_str) + .any(|pattern| { + let pattern = pattern.trim().to_lowercase(); + if pattern.is_empty() { + return false; + } + let Some((prefix, rest)) = pattern.split_once('*') else { + return pattern == model; + }; + let Some(mut remaining) = model.strip_prefix(prefix) else { + return false; + }; + let mut parts = rest.rsplitn(2, '*'); + let suffix = parts.next().unwrap_or_default(); + let Some(middle) = remaining.strip_suffix(suffix) else { + return false; + }; + remaining = middle; + if let Some(parts) = parts.next() { + for part in parts.split('*').filter(|part| !part.is_empty()) { + let Some(index) = remaining.find(part) else { + return false; + }; + remaining = &remaining[index + part.len()..]; + } + } + true + }) +} + fn resolve_claude_desktop_alias_source( root: &serde_norway::Mapping, source_model: &str, + alias: &str, ) -> Result, String> { for upstream in [false, true] { let matching_model = |model: &serde_norway::Value| { @@ -691,7 +983,7 @@ fn resolve_claude_desktop_alias_source( let Some(provider) = provider.as_mapping() else { continue; }; - if yaml_mapping_value(provider, "disabled") == Some(&serde_norway::Value::Bool(true)) { + if !configured_provider_model_is_enabled(provider, alias) { continue; } let Some(models) = yaml_mapping_value(provider, "models") else { @@ -700,7 +992,13 @@ fn resolve_claude_desktop_alias_source( let models = models .as_sequence() .ok_or_else(|| format!("{section}.models 必须是数组"))?; - if let Some(model) = models.iter().find_map(matching_model) { + if let Some(model) = models.iter().find_map(|model| { + let (_, client_model, _) = configured_model_identity(model)?; + if !configured_provider_model_is_enabled(provider, &client_model) { + return None; + } + matching_model(model) + }) { return Ok(Some(ClaudeDesktopAliasSource::Provider { section, provider_index, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index da6f56e..0006e0f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -164,9 +164,11 @@ const PI_AGENT_SETTINGS_FILE: &str = "settings.json"; const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json"; const CODEX_OAUTH_LOGIN_REQUIRED_ERROR: &str = "CODEX_OAUTH_LOGIN_REQUIRED"; const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700"; -const CLAUDE_DESKTOP_OPUS_MODEL_ID: &str = "claude-opus-5"; -const CLAUDE_DESKTOP_SONNET_MODEL_ID: &str = "claude-sonnet-4-6"; -const CLAUDE_DESKTOP_HAIKU_MODEL_ID: &str = "claude-haiku-4-5"; +const CLAUDE_DESKTOP_OPUS_MODEL_ID: &str = "claude-opus-5-cpa"; +const CLAUDE_DESKTOP_SONNET_MODEL_ID: &str = "claude-sonnet-5-cpa"; +const CLAUDE_DESKTOP_HAIKU_MODEL_ID: &str = "claude-haiku-4-5-cpa"; +const LEGACY_CLAUDE_DESKTOP_MODEL_IDS: [&str; 3] = + ["claude-opus-5", "claude-sonnet-4-6", "claude-haiku-4-5"]; const MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME: &str = "EasyCLIProxyAPI managed Claude Opus mapping"; const MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME: &str = "EasyCLIProxyAPI managed Claude Sonnet mapping"; @@ -1131,8 +1133,13 @@ struct AgentModelOption { #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct ClaudeDesktopModelMappings { + #[serde(default, skip_serializing_if = "Option::is_none")] + desktop_models: Option>, + #[serde(default)] opus: String, + #[serde(default)] sonnet: String, + #[serde(default)] haiku: String, #[serde(default)] opus_1m: bool, @@ -1148,6 +1155,41 @@ struct ClaudeDesktopModelMappings { disable_auto_compact: bool, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClaudeDesktopModelMapping { + #[serde(default)] + model: String, + #[serde(default)] + alias: String, + #[serde(default)] + context_1m: bool, +} + +impl ClaudeDesktopModelMapping { + fn model_id(&self) -> &str { + if self.alias.trim().is_empty() { + self.model.trim() + } else { + self.alias.trim() + } + } + + fn source_or_alias(&self) -> &str { + if self.model.trim().is_empty() { + self.alias.trim() + } else { + self.model.trim() + } + } + + fn has_mapping(&self) -> bool { + !self.model.trim().is_empty() + && !self.alias.trim().is_empty() + && !self.model.trim().eq_ignore_ascii_case(self.alias.trim()) + } +} + fn default_claude_code_max_context_tokens() -> u64 { DEFAULT_CLAUDE_CONTEXT_WINDOW } @@ -1159,6 +1201,7 @@ fn default_claude_auto_compact_pct() -> u8 { impl ClaudeDesktopModelMappings { fn all(model: &str) -> Self { Self { + desktop_models: None, opus: model.to_string(), sonnet: model.to_string(), haiku: model.to_string(), diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 3ae65dd..4c55a7c 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -116,6 +116,7 @@ fn claude_mapping_legacy_json_defaults_1m_preferences_off() { #[test] fn claude_code_role_mappings_drive_settings() { let mappings = ClaudeDesktopModelMappings { + desktop_models: None, opus: "gpt-opus".to_string(), sonnet: "gpt-sonnet".to_string(), haiku: "gpt-haiku".to_string(), @@ -184,6 +185,7 @@ fn claude_code_role_mappings_drive_settings() { #[test] fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { let mappings = ClaudeDesktopModelMappings { + desktop_models: None, opus: "custom-pro".to_string(), sonnet: "custom-pro".to_string(), haiku: "custom-flash".to_string(), @@ -261,7 +263,7 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { } #[test] -fn claude_desktop_keeps_original_context_when_1m_is_off() { +fn claude_desktop_omits_unsupported_context_window_when_1m_is_off() { let models = vec![AgentModelOption { input_modalities: None, harness_metadata: None, @@ -277,7 +279,7 @@ fn claude_desktop_keeps_original_context_when_1m_is_off() { &models, ); - assert_eq!(entry["contextWindow"], 1_000_000); + assert!(entry.get("contextWindow").is_none()); assert!(entry.get("supports1m").is_none()); assert!(entry.get("prefer1m").is_none()); } @@ -559,9 +561,11 @@ fn claude_desktop_config_builds_gateway_profile_and_index() { assert_eq!( profile["inferenceModels"], serde_json::json!([ - { "name": CLAUDE_DESKTOP_OPUS_MODEL_ID }, - { "name": CLAUDE_DESKTOP_SONNET_MODEL_ID }, - { "name": CLAUDE_DESKTOP_HAIKU_MODEL_ID } + { + "name": "claude-sonnet-test", + "anthropicFamilyTier": "opus", + "isFamilyDefault": true + } ]) ); assert_eq!(meta["appliedId"], CLAUDE_DESKTOP_PROFILE_ID); @@ -628,20 +632,29 @@ fn claude_desktop_profile_keeps_non_claude_models_internal() { serde_json::json!([ { "name": CLAUDE_DESKTOP_OPUS_MODEL_ID, - "contextWindow": 1000000, + "labelOverride": "gpt-5.6-sol", + "anthropicFamilyTier": "opus", + "isFamilyDefault": true, "supports1m": true, "prefer1m": true }, - { "name": CLAUDE_DESKTOP_SONNET_MODEL_ID, "contextWindow": 272000 }, + { + "name": CLAUDE_DESKTOP_SONNET_MODEL_ID, + "labelOverride": "gpt-5.6", + "anthropicFamilyTier": "sonnet", + "isFamilyDefault": true + }, { "name": CLAUDE_DESKTOP_HAIKU_MODEL_ID, - "contextWindow": 1000000, + "labelOverride": "gpt-5.6-mini", + "anthropicFamilyTier": "haiku", + "isFamilyDefault": true, "supports1m": true, "prefer1m": true } ]) ); - assert!(!profile.to_string().contains("gpt-")); + assert_eq!(profile["inferenceModels"][0]["labelOverride"], "gpt-5.6-sol"); } #[cfg(target_os = "windows")] @@ -1794,7 +1807,7 @@ fn claude_desktop_aliases_expose_role_routes_only() { ); assert!(!rendered.contains("models: [{")); assert!(rendered.contains("\n - name: gpt-5.6-sol\n")); - assert!(rendered.contains("\n alias: claude-opus-5\n")); + assert!(rendered.contains(&format!("\n alias: {CLAUDE_DESKTOP_OPUS_MODEL_ID}\n"))); assert!( rendered.contains("\n display-name: EasyCLIProxyAPI managed Claude Opus mapping\n") ); @@ -1891,22 +1904,7 @@ fn claude_desktop_aliases_support_claude_oauth_models() { .unwrap(); let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); let root = value.as_mapping().unwrap(); - let aliases = yaml_mapping_value(root, "oauth-model-alias") - .and_then(serde_norway::Value::as_mapping) - .and_then(|channels| yaml_mapping_value(channels, "claude")) - .and_then(serde_norway::Value::as_sequence) - .unwrap(); - - assert_eq!(aliases.len(), 3); - assert!(aliases.iter().all(|entry| { - configured_model_identity(entry).is_some_and(|(source, _, _)| source == "claude-sonnet-4-6") - })); - assert!(aliases.iter().all(|entry| { - entry - .as_mapping() - .and_then(|entry| yaml_mapping_value(entry, "fork")) - == Some(&serde_norway::Value::Bool(true)) - })); + assert!(yaml_mapping_value(root, "oauth-model-alias").is_none()); assert_eq!( ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( &rendered, @@ -2148,7 +2146,8 @@ fn claude_desktop_uses_selected_alias_directly_with_original_context() { profile["inferenceModels"], serde_json::json!([{ "name": "gpt-high", - "contextWindow": 1_000_000, + "anthropicFamilyTier": "sonnet", + "isFamilyDefault": true, "supports1m": true, "prefer1m": true }]) diff --git a/src-tauri/src/tests/alias_save.rs b/src-tauri/src/tests/alias_save.rs index 484284a..05b7bfa 100644 --- a/src-tauri/src/tests/alias_save.rs +++ b/src-tauri/src/tests/alias_save.rs @@ -340,13 +340,28 @@ struct DesktopRestoreFixture { impl DesktopRestoreFixture { fn new() -> Self { + Self::build(false) + } + + fn build(custom: bool) -> Self { + Self::build_with_direct_model(custom, false) + } + + fn build_with_direct_model(custom: bool, direct: bool) -> Self { let home = super::support::agent_test_home("desktop-restore"); let models = super::support::test_agent_models(&["model-a", "model-b"]); let initial = "openai-compatibility:\n - name: provider\n models: [{name: model-a}, {name: model-b}]\n"; let mut id = None; let mut core_versions = Vec::new(); for name in ["model-a", "model-b"] { - let mappings = ClaudeDesktopModelMappings::all(name); + let mut mappings = ClaudeDesktopModelMappings::all(name); + if custom { + mappings.desktop_models = Some(vec![ClaudeDesktopModelMapping { + model: name.into(), + alias: if direct && name == "model-a" { String::new() } else { format!("claude-sonnet-4-6-{name}") }, context_1m: true, + }]); + mappings.sonnet = mappings.desktop_models.as_ref().unwrap()[0].source_or_alias().to_string(); + } core_versions.push( ensure_claude_desktop_model_aliases_in_yaml(initial, &mappings, &models).unwrap(), ); @@ -406,6 +421,142 @@ async fn desktop_backup_restore_updates_core_routes_and_mapping_metadata() { assert_eq!(persisted, yaml_json(&fixture.core_a)); } +#[tokio::test(flavor = "current_thread")] +async fn desktop_legacy_backup_restores_the_routes_referenced_by_its_profile() { + for source in ["claude-sonnet-test", LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1]] { + let fixture = DesktopRestoreFixture::new(); + let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home); + let before = config_images(&paths).unwrap(); + let mut after = before.clone(); + let mut profile: serde_json::Value = serde_json::from_slice(after[2].1.as_ref().unwrap()).unwrap(); + profile["inferenceModels"] = serde_json::json!(LEGACY_CLAUDE_DESKTOP_MODEL_IDS + .map(|name| serde_json::json!({"name": name}))); + after[2].1 = Some(serde_json::to_vec(&profile).unwrap()); + let mappings = ClaudeDesktopModelMappings::all(source); + commit_config_with_mappings("claude-desktop", &paths, &before, &after, + "update", Some(source.into()), Some(mappings.clone())).unwrap(); + let backup = create_backup("claude-desktop", &fixture.home).unwrap(); + for routes_exist in [false, true] { + let initial = format!("{}claude-api-key:\n - models: [{{name: {source}}}]\n", fixture.core_b); + let mut initial = yaml_json(&initial); + if routes_exist { + for route in LEGACY_CLAUDE_DESKTOP_MODEL_IDS { + if route != source { + initial["claude-api-key"][0]["models"].as_array_mut().unwrap() + .push(serde_json::json!({ + "name": source, + "alias": route, + "display-name": managed_claude_alias_display_name(route).unwrap(), + })); + } + } + } + let core = MockCore::new(&serde_norway::to_string(&initial).unwrap(), Failure::None); + let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &backup.id) + .await.unwrap(); + let revision = plan.preview.revision.clone(); + execute_restore_plan(&core.config, plan, &revision).await.unwrap(); + assert_eq!(config_images(&paths).unwrap(), after); + assert_eq!(current_desktop_mappings(&fixture.home).unwrap(), mappings); + let (persisted, _) = core.finish(); + let models = persisted["claude-api-key"][0]["models"].as_array().unwrap(); + for route in LEGACY_CLAUDE_DESKTOP_MODEL_IDS { + assert!(models.iter().any(|model| model["name"] == source + && model.get("alias").unwrap_or(&model["name"]) == route), "missing {route}: {persisted}"); + } + assert!(models.iter().any(|model| model["name"] == source && model.get("alias").is_none())); + assert!(persisted["openai-compatibility"][0]["models"].as_array().unwrap() + .iter().all(|model| model.get("alias").is_none())); + } + } +} + +#[tokio::test(flavor = "current_thread")] +async fn desktop_backup_keeps_a_direct_legacy_id_alongside_a_new_family_route() { + let fixture = DesktopRestoreFixture::new(); + let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home); + let before = config_images(&paths).unwrap(); + let mut after = before.clone(); + let direct = LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]; + let mut profile: serde_json::Value = serde_json::from_slice(after[2].1.as_ref().unwrap()).unwrap(); + profile["inferenceModels"] = serde_json::json!([ + {"name": CLAUDE_DESKTOP_OPUS_MODEL_ID, "labelOverride": "gpt-one"}, + {"name": direct}, + {"name": CLAUDE_DESKTOP_HAIKU_MODEL_ID, "labelOverride": "gpt-two"}, + ]); + after[2].1 = Some(serde_json::to_vec(&profile).unwrap()); + let mappings = ClaudeDesktopModelMappings { + opus: "gpt-one".into(), + sonnet: direct.into(), + haiku: "gpt-two".into(), + ..ClaudeDesktopModelMappings::all("") + }; + commit_config_with_mappings("claude-desktop", &paths, &before, &after, + "update", Some(direct.into()), Some(mappings)).unwrap(); + let backup = create_backup("claude-desktop", &fixture.home).unwrap(); + let initial = format!("openai-compatibility:\n - name: provider\n models: [{{name: gpt-one}}, {{name: gpt-two}}, {{name: {direct}}}]\n"); + let core = MockCore::new(&initial, Failure::None); + let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &backup.id) + .await.unwrap(); + let revision = plan.preview.revision.clone(); + execute_restore_plan(&core.config, plan, &revision).await.unwrap(); + assert_eq!(config_images(&paths).unwrap(), after); + let (persisted, _) = core.finish(); + let models = persisted["openai-compatibility"][0]["models"].as_array().unwrap(); + assert!(models.iter().any(|model| model["name"] == direct && model.get("alias").is_none())); + for (alias, source) in [(CLAUDE_DESKTOP_OPUS_MODEL_ID, "gpt-one"), (CLAUDE_DESKTOP_HAIKU_MODEL_ID, "gpt-two")] { + assert!(models.iter().any(|model| model["name"] == source && model["alias"] == alias)); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn desktop_custom_backup_restores_aliases_profile_and_mapping_metadata() { + let fixture = DesktopRestoreFixture::build(true); + let core = MockCore::new(&fixture.core_b, Failure::None); + let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) + .await.unwrap(); + assert!(plan.preview.differences.iter().any(|difference| + difference.field == "modelMappings.claude-sonnet-4-6-model-a")); + assert!(plan.preview.differences.iter().any(|difference| + difference.field == "modelMappings.claude-sonnet-4-6-model-b")); + let revision = plan.preview.revision.clone(); + execute_restore_plan(&core.config, plan, &revision).await.unwrap(); + let mappings = current_desktop_mappings(&fixture.home).unwrap(); + let entries = mappings.desktop_models.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].model, "model-a"); + assert_eq!(entries[0].alias, "claude-sonnet-4-6-model-a"); + assert!(entries[0].context_1m); + let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home); + let profile: serde_json::Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); + assert_eq!(profile["inferenceModels"][0]["name"], entries[0].alias); + assert_eq!(profile["inferenceModels"][0]["labelOverride"], "model-a"); + let (persisted, _) = core.finish(); + assert_eq!(persisted, yaml_json(&fixture.core_a)); +} + +#[tokio::test(flavor = "current_thread")] +async fn desktop_direct_model_backup_restores_without_an_alias_route() { + let fixture = DesktopRestoreFixture::build_with_direct_model(true, true); + let core = MockCore::new(&fixture.core_b, Failure::None); + let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) + .await.unwrap(); + let revision = plan.preview.revision.clone(); + execute_restore_plan(&core.config, plan, &revision).await.unwrap(); + let mappings = current_desktop_mappings(&fixture.home).unwrap(); + let entry = &mappings.desktop_models.as_ref().unwrap()[0]; + assert!(entry.alias.is_empty()); + assert_eq!(entry.model, "model-a"); + assert_eq!(mappings.sonnet, entry.model); + let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home); + let profile: serde_json::Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); + assert_eq!(profile["inferenceModels"][0]["name"], entry.model); + assert_eq!(profile["inferenceModels"][0]["labelOverride"], entry.model); + let (persisted, _) = core.finish(); + assert_eq!(persisted, yaml_json(&fixture.core_a)); + assert!(!persisted.to_string().contains("\"alias\"")); +} + #[tokio::test(flavor = "current_thread")] async fn desktop_backup_restore_rejects_core_changes_since_preview() { let fixture = DesktopRestoreFixture::new(); diff --git a/src-tauri/src/tests/desktop_alias_routing.rs b/src-tauri/src/tests/desktop_alias_routing.rs index b01b57c..1496fe5 100644 --- a/src-tauri/src/tests/desktop_alias_routing.rs +++ b/src-tauri/src/tests/desktop_alias_routing.rs @@ -5,6 +5,229 @@ fn json(content: &str) -> serde_json::Value { serde_json::to_value(serde_norway::from_str::(content).unwrap()).unwrap() } +fn custom_mappings(entries: &[(&str, &str)]) -> ClaudeDesktopModelMappings { + ClaudeDesktopModelMappings { + desktop_models: Some(entries.iter().map(|(model, alias)| ClaudeDesktopModelMapping { + model: (*model).into(), alias: (*alias).into(), context_1m: false, + }).collect()), + ..ClaudeDesktopModelMappings::all("") + } +} + +#[test] +fn desktop_aliases_reject_other_model_families() { + for alias in ["claude-grok-4.6", "claude-opus-gpt-5", "claude-gemini", + "claude-deepseek", "claude-opus-phi4", "claude-opus-k2.5", "claude-m2.1", + "claude-ling", "claude-unic", "claude-ds-test"] { + assert!(!valid_claude_desktop_alias(alias), "{alias}"); + assert!(!is_claude_native_model_id(alias), "{alias}"); + let mappings = custom_mappings(&[("gpt-one", alias)]); + assert!(validate_claude_desktop_entries(mappings.desktop_models.as_ref().unwrap()).is_err()); + } + for alias in ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5", + "claude-fable-5-1", "claude-opus-personal", "claude-linguist", + "claude-unicorn", "claude-ranking2.1"] { + assert!(valid_claude_desktop_alias(alias), "{alias}"); + } +} + +#[test] +fn desktop_replacing_a_previously_allowed_alias_removes_its_old_route() { + let input = "codex-api-key:\n - models:\n - name: gpt-one\n - name: gpt-one\n alias: claude-grok-4.6\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"; + let mappings = custom_mappings(&[("gpt-one", "claude-opus-personal")]); + let after = json(&ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &[]).unwrap()); + let models = after["codex-api-key"][0]["models"].as_array().unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[1]["alias"], "claude-opus-personal"); +} + +#[test] +fn desktop_unchanged_alias_sync_preserves_legacy_yaml_verbatim() { + let input = "# Existing core configuration\nrouting:\n strategy: round-robin\n# Retention duration\n\n session-affinity-ttl: ''\n# Inherit parent sessions\n\n session-affinity-subagents: true\ncodex-api-key:\n - api-key: preserved-key\n models:\n - name: model-a\n - name: model-a\n alias: claude-opus-custom\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"; + for mappings in [ + custom_mappings(&[("model-a", "claude-opus-custom")]), + custom_mappings(&[("claude-opus-custom", "")]), + ClaudeDesktopModelMappings::all("claude-opus-custom"), + ] { + let updated = ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &[]).unwrap(); + assert_eq!(updated, input); + } +} + +#[test] +fn desktop_empty_alias_uses_original_id_without_creating_a_mapping() { + let mappings = custom_mappings(&[(" claude-sonnet-custom-7 ", " ")]); + let model = resolve_agent_configuration_model( + AgentClient::ClaudeDesktop, &[], "", Some(&mappings), + ).unwrap(); + assert_eq!(model, "claude-sonnet-custom-7"); + let mappings = resolve_claude_desktop_model_mappings( + AgentClient::ClaudeDesktop, &[], &model, Some(mappings), + ).unwrap().unwrap(); + assert_eq!(mappings.sonnet, model); + assert_eq!(mappings.desktop_models.as_ref().unwrap()[0].model, model); + assert!(mappings.desktop_models.as_ref().unwrap()[0].alias.is_empty()); + let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile( + None, "http://localhost:8317", "key", &model, &[], Some(&mappings), + ).unwrap()).unwrap(); + assert_eq!(profile["inferenceModels"][0]["name"], model); + assert_eq!(profile["inferenceModels"][0]["labelOverride"], model); + let input = "codex-api-key:\n - models: [{name: gpt-one}]\n"; + let updated = ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &[]).unwrap(); + assert_eq!(json(&updated), json(input)); + assert!(resolve_agent_configuration_model( + AgentClient::ClaudeCode, &[], &model, Some(&mappings), + ).is_err()); +} + +#[test] +fn desktop_legacy_alias_only_entry_resolves_to_original_with_empty_alias() { + let mappings = custom_mappings(&[("", "claude-sonnet-custom-7")]); + let model = resolve_agent_configuration_model( + AgentClient::ClaudeDesktop, &[], "", Some(&mappings), + ).unwrap(); + let resolved = resolve_claude_desktop_model_mappings( + AgentClient::ClaudeDesktop, &[], &model, Some(mappings), + ).unwrap().unwrap(); + let entry = &resolved.desktop_models.as_ref().unwrap()[0]; + assert_eq!(entry.model, "claude-sonnet-custom-7"); + assert!(entry.alias.is_empty()); +} + +#[test] +fn desktop_clearing_alias_removes_the_old_mapping_and_keeps_original_and_other_rows() { + let input = "codex-api-key:\n - models: [{name: gpt-one}, {name: gpt-two}, {name: claude-opus-4-6}]\n"; + let models = test_agent_models(&["gpt-one", "gpt-two", "claude-opus-4-6"]); + let mapped = custom_mappings(&[("gpt-one", "claude-sonnet-4-6"), ("gpt-two", "claude-haiku-4-5")]); + let before = ensure_claude_desktop_model_aliases_in_yaml(input, &mapped, &models).unwrap(); + let direct = custom_mappings(&[("gpt-one", ""), ("gpt-two", "claude-haiku-4-5"), ("claude-opus-4-6", "")]); + let updated = ensure_claude_desktop_model_aliases_in_yaml(&before, &direct, &models).unwrap(); + let entries = json(&updated)["codex-api-key"][0]["models"].as_array().unwrap().clone(); + assert_eq!(entries.len(), 4); + assert!(!entries.iter().any(|entry| entry["alias"] == "claude-sonnet-4-6")); + assert!(entries.iter().any(|entry| entry["name"] == "gpt-one" && entry.get("alias").is_none())); + assert!(entries.iter().any(|entry| entry["name"] == "gpt-two" && entry["alias"] == "claude-haiku-4-5")); + assert!(entries.iter().any(|entry| entry["name"] == "claude-opus-4-6")); + let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile( + None, "http://localhost:8317", "key", "gpt-one", &models, Some(&direct), + ).unwrap()).unwrap(); + assert_eq!(profile["inferenceModels"][0]["name"], "gpt-one"); + assert_eq!(profile["inferenceModels"][0]["labelOverride"], "gpt-one"); + assert_eq!(profile["inferenceModels"][1]["name"], "claude-haiku-4-5"); + assert_eq!(profile["inferenceModels"][2]["name"], "claude-opus-4-6"); +} + +#[test] +fn desktop_custom_list_routes_every_selected_model_and_displays_original_names() { + let mappings = custom_mappings(&[ + ("gpt-one", "claude-opus-4-6"), ("gpt-two", "claude-sonnet-4-6"), + ("gpt-one", "claude-haiku-4-5"), ("gpt-two", "claude-sonnet-4-5"), + ]); + let models = test_agent_models(&["gpt-one", "gpt-two"]); + let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile( + None, "http://localhost:8317", "test-key", "gpt-one", &models, Some(&mappings), + ).unwrap()).unwrap(); + let entries = profile["inferenceModels"].as_array().unwrap(); + assert_eq!(entries.len(), 4); + assert_eq!(entries[0]["name"], "claude-opus-4-6"); + assert_eq!(entries[0]["labelOverride"], "gpt-one"); + assert_eq!(entries[1]["isFamilyDefault"], true); + assert_eq!(entries[3]["isFamilyDefault"], false); + let input = "codex-api-key:\n - models: [{name: gpt-one}, {name: gpt-two}]\n"; + let routed = ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &models).unwrap(); + let after = json(&routed); + assert_eq!(after["codex-api-key"][0]["models"].as_array().unwrap().len(), 6); + for entry in mappings.desktop_models.unwrap() { + assert!(after["codex-api-key"][0]["models"].as_array().unwrap().iter() + .any(|model| model["alias"] == entry.alias && model["name"] == entry.model)); + } +} + +#[test] +fn desktop_custom_alias_edits_remove_old_managed_routes_and_preserve_user_routes() { + let input = "codex-api-key:\n - models: [{name: gpt-one}, {name: gpt-two}, {name: gpt-one, alias: claude-user-model}]\n"; + let models = test_agent_models(&["gpt-one", "gpt-two", "claude-user-model"]); + let first = ensure_claude_desktop_model_aliases_in_yaml(input, + &custom_mappings(&[("gpt-one", "claude-opus-4-6"), ("gpt-two", "claude-sonnet-4-6")]), &models).unwrap(); + let second = ensure_claude_desktop_model_aliases_in_yaml(&first, + &custom_mappings(&[("gpt-two", "claude-haiku-4-5")]), &models).unwrap(); + let entries = json(&second)["codex-api-key"][0]["models"].as_array().unwrap().clone(); + assert_eq!(entries.len(), 4); + assert!(entries.iter().any(|m| m["alias"] == "claude-user-model")); + assert!(!entries.iter().any(|m| m["alias"] == "claude-opus-4-6" || m["alias"] == "claude-sonnet-4-6")); + assert!(ensure_claude_desktop_model_aliases_in_yaml(input, + &custom_mappings(&[("gpt-two", "claude-user-model")]), &models).unwrap_err().contains("已被其他模型使用")); + assert!(ensure_claude_desktop_model_aliases_in_yaml(input, + &custom_mappings(&[("gpt-two", "claude-native-model")]), + &test_agent_models(&["gpt-two", "claude-native-model"])).is_err()); + let duplicate = format!("{first}openai-compatibility:\n - models: [{{name: claude-opus-4-6}}]\n"); + assert!(ensure_claude_desktop_model_aliases_in_yaml(&duplicate, + &custom_mappings(&[("gpt-two", "claude-opus-4-6")]), &models).is_err()); +} + +#[test] +fn desktop_custom_mapping_validates_aliases_and_accepts_one_model() { + let models = test_agent_models(&["gpt-one"]); + let resolved = resolve_claude_desktop_model_mappings(AgentClient::ClaudeDesktop, &models, "gpt-one", + Some(custom_mappings(&[("gpt-one", " claude-sonnet-4-6 ")]))).unwrap().unwrap(); + assert_eq!(resolved.sonnet, "gpt-one"); + assert_eq!(resolved.desktop_models.as_ref().unwrap()[0].alias, "claude-sonnet-4-6"); + for entries in [vec![], vec![("", "")], vec![("gpt-one", "gpt-alias")], vec![("two words", "claude-sonnet-4-6")], + vec![("claude-sonnet-4-6", ""), ("gpt-one", "claude-sonnet-4-6")], + vec![("gpt-one", "claude-sonnet-4-6"), ("gpt-one", "claude-sonnet-4-6")]] { + assert!(resolve_claude_desktop_model_mappings(AgentClient::ClaudeDesktop, &models, "gpt-one", + Some(custom_mappings(&entries))).is_err()); + } +} + +#[test] +fn desktop_manual_source_can_create_an_alias_when_missing_from_the_loaded_model_list() { + let mappings = custom_mappings(&[(" manual-model ", "claude-opus-5")]); + let model = resolve_agent_configuration_model( + AgentClient::ClaudeDesktop, &[], "", Some(&mappings), + ).unwrap(); + assert_eq!(model, "manual-model"); + let mappings = resolve_claude_desktop_model_mappings( + AgentClient::ClaudeDesktop, &[], &model, Some(mappings), + ).unwrap().unwrap(); + let input = "openai-compatibility:\n - name: manual-provider\n models: [{name: manual-model}]\n"; + let routed = json(&ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &[]).unwrap()); + assert!(routed["openai-compatibility"][0]["models"].as_array().unwrap().iter() + .any(|entry| entry["name"] == model && entry["alias"] == "claude-opus-5")); + assert!(ensure_claude_desktop_model_aliases_in_yaml("{}", &mappings, &[]) + .unwrap_err().contains("无法确定模型 manual-model")); +} + +#[test] +fn desktop_same_original_and_alias_is_normalized_to_no_alias() { + let mappings = custom_mappings(&[("anthropic/claude-opus-5", " ANTHROPIC/CLAUDE-OPUS-5 ")]); + let resolved = resolve_claude_desktop_model_mappings( + AgentClient::ClaudeDesktop, &[], "", Some(mappings), + ).unwrap().unwrap(); + let entry = &resolved.desktop_models.as_ref().unwrap()[0]; + assert_eq!(entry.model, "anthropic/claude-opus-5"); + assert!(entry.alias.is_empty()); +} + +#[test] +fn desktop_custom_aliases_preserve_source_alias_options_and_context() { + let input = "codex-api-key:\n - models: [{name: gpt-one, alias: gpt-high, reasoning-effort: high}]\n"; + let mut models = test_agent_models(&["gpt-high"]); + models[0].is_alias = true; + models[0].alias = Some("gpt-one".into()); + let mut mappings = custom_mappings(&[("gpt-high", "claude-opus-4-6")]); + mappings.desktop_models.as_mut().unwrap()[0].context_1m = true; + let after = json(&ensure_claude_desktop_model_aliases_in_yaml(input, &mappings, &models).unwrap()); + let entry = &after["codex-api-key"][0]["models"][1]; + assert_eq!(entry["name"], "gpt-one"); + assert_eq!(entry["reasoning-effort"], "high"); + let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile( + None, "http://localhost:8317", "key", "gpt-high", &models, Some(&mappings)).unwrap()).unwrap(); + assert_eq!(profile["inferenceModels"][0]["name"], "claude-opus-4-6"); + assert_eq!(profile["inferenceModels"][0]["labelOverride"], "gpt-high"); + assert_eq!(profile["inferenceModels"][0]["supports1m"], true); +} + #[test] fn desktop_routes_preserve_api_access_when_switching_models() { let input = "codex-api-key:\n - api-key: codex-test-key\n base-url: https://codex.example.test\n proxy-url: socks5://127.0.0.1:1080\n models: [{name: model-a}, {name: model-b}]\nopenai-compatibility:\n - name: other\n disabled: true\n api-key-entries: [{api-key: other-test-key}]\n models: [{name: other-model}]\npayload:\n override:\n - models: [{name: other-model}]\n params: {custom: retained}\n"; @@ -34,6 +257,91 @@ fn desktop_routes_preserve_api_access_when_switching_models() { } } +#[test] +fn desktop_route_name_collision_keeps_the_existing_real_model() { + let input = format!( + "codex-api-key:\n - models: [{{name: {route}}}]\n", + route = CLAUDE_DESKTOP_OPUS_MODEL_ID, + ); + let models = test_agent_models(&[ + CLAUDE_DESKTOP_OPUS_MODEL_ID, + CLAUDE_DESKTOP_SONNET_MODEL_ID, + CLAUDE_DESKTOP_HAIKU_MODEL_ID, + ]); + let rendered = ensure_claude_desktop_model_aliases_in_yaml( + &input, + &ClaudeDesktopModelMappings { + opus: CLAUDE_DESKTOP_OPUS_MODEL_ID.to_string(), + sonnet: CLAUDE_DESKTOP_SONNET_MODEL_ID.to_string(), + haiku: CLAUDE_DESKTOP_HAIKU_MODEL_ID.to_string(), + ..ClaudeDesktopModelMappings::all("") + }, + &models, + ) + .unwrap(); + let after = json(&rendered); + let entries = after["codex-api-key"][0]["models"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["name"], CLAUDE_DESKTOP_OPUS_MODEL_ID); + assert!(entries[0].get("alias").is_none()); +} + +#[test] +fn desktop_route_name_collision_keeps_existing_legacy_claude_model() { + let input = format!( + "codex-api-key:\n - models: [{{name: {legacy}}}]\n", + legacy = LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0], + ); + let models = test_agent_models(&[LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]]); + let rendered = ensure_claude_desktop_model_aliases_in_yaml( + &input, + &ClaudeDesktopModelMappings::all(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]), + &models, + ) + .unwrap(); + let after = json(&rendered); + let entries = after["codex-api-key"][0]["models"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["name"], LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]); + assert!(entries[0].get("alias").is_none()); +} + +#[test] +fn desktop_routes_skip_excluded_sources_and_aliases() { + let mappings = custom_mappings(&[("model-a", "claude-opus-5")]); + for excluded in ["*", "model-a", "model-*", "claude-opus-5", " CLAUDE-* ", "*-opus-*", "claude-*-5"] { + let input = format!("codex-api-key:\n - api-key: excluded-key\n excluded-models: ['{excluded}']\n models: [{{name: model-a}}]\n - api-key: active-key\n models: [{{name: model-a, context-length: 123456}}]\n"); + let updated = ensure_claude_desktop_model_aliases_in_yaml(&input, &mappings, &[]).unwrap(); + let after = json(&updated); + assert_eq!(after["codex-api-key"][0], json(&input)["codex-api-key"][0], "{excluded}"); + assert_eq!(after["codex-api-key"][1]["models"][1]["alias"], "claude-opus-5", "{excluded}"); + assert_eq!(after["codex-api-key"][1]["models"][1]["context-length"], 123456); + } +} + +#[test] +fn desktop_routes_repair_managed_aliases_on_excluded_providers() { + for mappings in [ + custom_mappings(&[("model-a", "claude-opus-5")]), + ClaudeDesktopModelMappings::all("model-a"), + ] { + let initial = "codex-api-key:\n - api-key: old-key\n models: [{name: model-a}]\n"; + let configured = ensure_claude_desktop_model_aliases_in_yaml(initial, &mappings, &[]).unwrap(); + for excluded in ["*", "claude-*"] { + let blocked = configured.replacen("api-key: old-key", &format!("api-key: old-key\n excluded-models: ['{excluded}']"), 1); + assert!(ensure_claude_desktop_model_aliases_in_yaml(&blocked, &mappings, &[]).is_err()); + let input = format!("{blocked} - api-key: active-key\n models: [{{name: model-a}}]\n"); + let repaired = ensure_claude_desktop_model_aliases_in_yaml(&input, &mappings, &[]).unwrap(); + let after = json(&repaired); + assert_eq!(after["codex-api-key"][0]["excluded-models"], serde_json::json!([excluded])); + assert_eq!(after["codex-api-key"][0]["models"], serde_json::json!([{"name": "model-a"}])); + let expected_count = if mappings.desktop_models.is_some() { 2 } else { 4 }; + assert_eq!(after["codex-api-key"][1]["models"].as_array().unwrap().len(), expected_count); + assert_eq!(ensure_claude_desktop_model_aliases_in_yaml(&repaired, &mappings, &[]).unwrap(), repaired); + } + } +} + #[test] fn desktop_routes_move_from_disabled_provider_to_enabled_source() { let models = test_agent_models(&["grok-4.6"]); @@ -111,10 +419,12 @@ fn desktop_routes_reject_sources_available_only_in_disabled_providers() { #[test] fn disabled_provider_alias_does_not_mark_an_active_real_model_as_an_alias() { - let input = "openai-compatibility:\n - name: disabled-provider\n disabled: true\n models: [{name: other-model, alias: model-a}]\ncodex-api-key:\n - models: [{name: model-a}]\n"; - let mut models = test_agent_models(&["model-a"]); - mark_configured_agent_model_aliases(&mut models, input).unwrap(); - assert!(!models[0].is_alias); + for exclusions in ["disabled: true", "excluded-models: ['*']", "excluded-models: ['MODEL-*']"] { + let input = format!("codex-api-key:\n - {exclusions}\n models: [{{name: other-model, alias: model-a}}]\n - models: [{{name: model-a}}]\n"); + let mut models = test_agent_models(&["model-a"]); + mark_configured_agent_model_aliases(&mut models, &input).unwrap(); + assert!(!models[0].is_alias, "{exclusions}"); + } } #[test] diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index dab90d1..6b2d130 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1085,8 +1085,31 @@ export const jaOverrides = { "agents.modify.moreDescription": "再構築やクリアは独自設定を削除します。必要な場合のみ使用してください。", "agents.modify.title": "{name} の設定を書き込む", 'agents.modify.oauthConfiguration': 'OAuth ログイン設定方式', - 'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル', - 'agents.claudeDesktopMapping.description': '役割ごとに上流モデルとコンテキスト機能を選択します。対応モデルでのみ 1M を有効にしてください。', + 'agents.claudeDesktopMapping.title': 'Claude Desktop モデルとエイリアス', + 'agents.claudeDesktopMapping.help': '使い方', + 'agents.claudeDesktopMapping.description': 'Claude Desktop では Claude 系列のモデルのみ使用できるため、他の系列のモデルには Claude 形式のエイリアスを設定する必要があります。元のモデル名がすでに Claude 系列なら、エイリアスの設定は不要です。空欄のままにしてください。', + 'agents.claudeDesktopMapping.collisionHint': 'エイリアスは、元モデル一覧にある既存のモデル ID と重複しない名前を推奨します。同じ名前があるとラウンドロビン方式でリクエストが別のモデルに振り分けられ、目的のモデルを呼び出せない場合があります。', + 'agents.claudeDesktopMapping.add': 'モデルを追加', + 'agents.claudeDesktopMapping.remove': 'モデルを削除', + 'agents.claudeDesktopMapping.model': '元モデル', + 'agents.claudeDesktopMapping.alias': 'エイリアス(Claude モデルは省略可)', + 'agents.claudeDesktopMapping.claudeDefault': 'Claude モデルは既定で元の ID を使用します', + 'agents.claudeDesktopMapping.sameModel': 'エイリアスが元モデルと同じです。設定は不要で、元の ID をそのまま使用します。', + 'agents.claudeDesktopMapping.defaultAliasExists': '既定のエイリアス「{alias}」はモデル一覧に存在します。別のモデルが呼び出されないよう、他のエイリアスに切り替えてください。', + 'agents.claudeDesktopMapping.noRename': '元の ID を使用', + 'agents.claudeDesktopMapping.suggestedAlias': 'エイリアス候補', + 'agents.claudeDesktopMapping.suggestionCount': '{count} 件のエイリアス候補', + 'agents.claudeDesktopMapping.customAliasHint': '一致する候補はありません。自由にエイリアスを入力できます。', + 'agents.model.useCustom': '入力したモデル ID を使用', + 'agents.claudeDesktopMapping.newModel': '新しいモデル', + 'agents.claudeDesktopMapping.idHint': 'エイリアスは claude-opus-*、claude-sonnet-*、claude-haiku-* などの形式にできます。ただし gpt・grok・gemini・deepseek など、他のモデル系列の名前は含められません。星印は任意の接尾辞を表します。そのまま入力せず、使いたい文字列に置き換えてください。', + 'agents.claudeDesktopMapping.empty': '「モデルを追加」で元モデルを入力してください。エイリアスは省略できます。', + 'agents.claudeDesktopMapping.error.empty': '元モデルを1つ以上選択または入力してください。', + 'agents.claudeDesktopMapping.error.model': '元モデルを選択または入力してください。', + 'agents.claudeDesktopMapping.error.modelFormat': '元モデル ID の形式が無効です。空白を削除するか、名前を短くしてください。', + 'agents.claudeDesktopMapping.error.aliasExists': 'このエイリアスは既存の元モデル ID と同じです。別のエイリアスを指定してください。', + 'agents.claudeDesktopMapping.error.alias': 'エイリアスは claude- で始まり、小文字の英数字・ハイフン・ピリオドのみ使用できます。gpt・grok・gemini・deepseek など他のモデル系列の名前は含められません。', + 'agents.claudeDesktopMapping.error.duplicate': 'エイリアスまたは元モデル ID が重複しています。各行に異なる ID を指定してください。', 'agents.claudeCodeMapping.title': 'Claude Code モデル → 上流モデル', 'agents.claudeCodeMapping.description': 'タスク役割ごとに上流モデルを選び、共通のコンテキストと自動圧縮ポリシーを設定します。', 'agents.claudeCodeRuntime.maxContextTokens': '最大コンテキスト', @@ -1098,9 +1121,9 @@ export const jaOverrides = { 'agents.claudeCodeRuntime.disableAutoCompactHint': '有効時は /compact による手動圧縮のみ利用できます。', 'agents.claudeDesktopMapping.customMapping': 'モデルエイリアスを使用', 'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。', - 'agents.claudeDesktopMapping.opus': 'Claude Opus 5', - 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6', - 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5', + 'agents.claudeDesktopMapping.opus': 'Opus', + 'agents.claudeDesktopMapping.sonnet': 'Sonnet', + 'agents.claudeDesktopMapping.haiku': 'Haiku', 'agents.claudeMapping.context1m': '1M', 'agents.claudeMapping.context1mHint': '1M コンテキストとして設定します。選択した上流モデルが対応していることを確認してください。', 'agents.oauthLoginRequired.title': 'Codex へのログインが必要です', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c224738..1cf891d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1051,8 +1051,31 @@ export const en: Record = { 'agents.notFetched': 'Not available', 'agents.useModel': 'Model', 'agents.pending': 'Not Applied', - 'agents.claudeDesktopMapping.title': 'Claude Desktop Model → Upstream Model', - 'agents.claudeDesktopMapping.description': 'Choose the upstream model and context capability for each role. Turn on 1M only for supported models.', + 'agents.claudeDesktopMapping.title': 'Claude Desktop Models and Aliases', + 'agents.claudeDesktopMapping.help': 'Usage guide', + 'agents.claudeDesktopMapping.description': 'Claude Desktop only allows Claude models, so non-Claude models need aliases that follow Claude model naming. If the original model name already belongs to the Claude family, no alias mapping is needed; leave the alias blank.', + 'agents.claudeDesktopMapping.collisionHint': 'Avoid aliases that match existing IDs in the original model list. Duplicate names can trigger round-robin scheduling, sending requests to another model instead of the one you intended.', + 'agents.claudeDesktopMapping.add': 'Add model', + 'agents.claudeDesktopMapping.remove': 'Remove model', + 'agents.claudeDesktopMapping.model': 'Original model', + 'agents.claudeDesktopMapping.alias': 'Alias (optional for Claude models)', + 'agents.claudeDesktopMapping.claudeDefault': 'Claude models use their original ID by default', + 'agents.claudeDesktopMapping.sameModel': 'The alias matches the original model. No alias is needed; the original ID will be used.', + 'agents.claudeDesktopMapping.defaultAliasExists': 'The default alias “{alias}” already exists in the model list. Choose another alias to avoid calling a different model.', + 'agents.claudeDesktopMapping.noRename': 'Keep original ID', + 'agents.claudeDesktopMapping.suggestedAlias': 'Suggested alias', + 'agents.claudeDesktopMapping.suggestionCount': '{count} suggested aliases', + 'agents.claudeDesktopMapping.customAliasHint': 'No matching suggestions. You can enter your own alias.', + 'agents.model.useCustom': 'Use the entered model ID', + 'agents.claudeDesktopMapping.newModel': 'New model', + 'agents.claudeDesktopMapping.idHint': 'Aliases can follow patterns such as claude-opus-*, claude-sonnet-*, or claude-haiku-*, but must not contain names of other model families such as gpt, grok, gemini, or deepseek. Replace the asterisk with your own suffix; do not enter it literally.', + 'agents.claudeDesktopMapping.empty': 'Click “Add model” and enter the original model. The alias is optional.', + 'agents.claudeDesktopMapping.error.empty': 'Choose or enter at least one original model.', + 'agents.claudeDesktopMapping.error.model': 'Choose or enter an original model.', + 'agents.claudeDesktopMapping.error.modelFormat': 'Invalid original model ID. Remove whitespace or shorten the name.', + 'agents.claudeDesktopMapping.error.aliasExists': 'This alias matches an existing original model ID. Choose a different alias.', + 'agents.claudeDesktopMapping.error.alias': 'Aliases must start with claude- and use lowercase letters, numbers, hyphens, and dots. Do not include other model families such as gpt, grok, gemini, or deepseek.', + 'agents.claudeDesktopMapping.error.duplicate': 'Duplicate alias or original model ID. Use a different ID for each row.', 'agents.claudeCodeMapping.title': 'Claude Code Model → Upstream Model', 'agents.claudeCodeMapping.description': 'Choose the upstream model for each task role and configure one shared context and auto-compact policy.', 'agents.claudeCodeRuntime.maxContextTokens': 'Maximum context', @@ -1064,9 +1087,9 @@ export const en: Record = { 'agents.claudeCodeRuntime.disableAutoCompactHint': 'Only manual /compact remains available when enabled.', 'agents.claudeDesktopMapping.customMapping': 'Use Model Aliases', 'agents.claudeDesktopMapping.customMappingHint': 'When enabled, only aliased models are shown; when disabled, only original models without aliases are shown.', - 'agents.claudeDesktopMapping.opus': 'Claude Opus 5', - 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6', - 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5', + 'agents.claudeDesktopMapping.opus': 'Opus', + 'agents.claudeDesktopMapping.sonnet': 'Sonnet', + 'agents.claudeDesktopMapping.haiku': 'Haiku', 'agents.claudeMapping.context1m': '1M', 'agents.claudeMapping.context1mHint': 'Configures a 1M context window. Make sure the selected upstream model supports it.', 'agents.model.readingAvailable': 'Loading available models', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 176bf53..a387e4e 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -851,6 +851,7 @@ export const zhCN = { 'agents.model.tryKeywords': '尝试其他关键词', 'agents.model.connectFirst': '请先接入可用模型后刷新', 'agents.model.available': '可用模型', + 'agents.model.useCustom': '使用输入的模型 ID', 'agents.model.count': '{count} 个可用模型', 'agents.model.stale': '刷新失败,显示上次结果', 'agents.error.modelsLoading': '模型列表仍在加载,请稍后再试', @@ -1050,8 +1051,30 @@ export const zhCN = { 'agents.notFetched': '未获取', 'agents.useModel': '使用模型', 'agents.pending': '尚未应用', - 'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型', - 'agents.claudeDesktopMapping.description': '分别选择上游模型和上下文能力;仅为确认支持的模型打开 1M。', + 'agents.claudeDesktopMapping.title': 'Claude Desktop 模型与别名', + 'agents.claudeDesktopMapping.help': '使用说明', + 'agents.claudeDesktopMapping.description': 'Claude Desktop 只允许使用 Claude 系列模型,因此需要将非 Claude 系列模型映射为 Claude 系列的模型别名。如果原模型名称已经属于 Claude 系列,则无需映射,别名留空即可。', + 'agents.claudeDesktopMapping.collisionHint': '映射后的别名最好不要与原模型列表中已有的模型 ID 相同,以免产生轮询调度,导致请求被分配给其他模型,无法调用到期望的模型。', + 'agents.claudeDesktopMapping.add': '添加模型', + 'agents.claudeDesktopMapping.remove': '移除模型', + 'agents.claudeDesktopMapping.model': '原模型', + 'agents.claudeDesktopMapping.alias': '别名(Claude系列模型可留空)', + 'agents.claudeDesktopMapping.claudeDefault': 'Claude系列模型默认不映射', + 'agents.claudeDesktopMapping.sameModel': '别名与原模型相同,无需重复设置,将直接使用原模型 ID。', + 'agents.claudeDesktopMapping.defaultAliasExists': '默认别名「{alias}」已存在于模型列表中,请切换其他别名,避免调用到其他模型。', + 'agents.claudeDesktopMapping.noRename': '不重名', + 'agents.claudeDesktopMapping.suggestedAlias': '别名建议', + 'agents.claudeDesktopMapping.suggestionCount': '{count} 个别名建议', + 'agents.claudeDesktopMapping.customAliasHint': '没有匹配的建议,可自行填写别名。', + 'agents.claudeDesktopMapping.newModel': '新模型', + 'agents.claudeDesktopMapping.idHint': '别名可以采用 claude-opus-*、claude-sonnet-*、claude-haiku-* 这样的格式,但不能包含 gpt、grok、gemini、deepseek 等其他模型系列的名称。星号表示自定义后缀,不要原样输入。', + 'agents.claudeDesktopMapping.empty': '点击“添加模型”,填写原模型;别名可留空。', + 'agents.claudeDesktopMapping.error.empty': '请至少选择或填写一个原模型。', + 'agents.claudeDesktopMapping.error.model': '请选择或填写原模型。', + 'agents.claudeDesktopMapping.error.modelFormat': '原模型 ID 格式无效,请去掉空白字符或缩短名称。', + 'agents.claudeDesktopMapping.error.aliasExists': '该别名与已有原模型 ID 相同,请更换别名。', + 'agents.claudeDesktopMapping.error.alias': '别名须以 claude- 开头,仅使用小写字母、数字、连字符和小数点,且不能包含 gpt、grok、gemini、deepseek 等其他模型系列名称。', + 'agents.claudeDesktopMapping.error.duplicate': '别名或原模型 ID 重复,请为每一行使用不同的 ID。', 'agents.claudeCodeMapping.title': 'Claude Code 模型 → 上游模型', 'agents.claudeCodeMapping.description': '分别为不同任务角色选择上游模型,并统一配置上下文窗口与自动压缩。', 'agents.claudeCodeRuntime.maxContextTokens': '最大窗口', @@ -1063,9 +1086,9 @@ export const zhCN = { 'agents.claudeCodeRuntime.disableAutoCompactHint': '开启后仅允许用户通过 /compact 手动压缩。', 'agents.claudeDesktopMapping.customMapping': '使用模型别名', 'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。', - 'agents.claudeDesktopMapping.opus': 'Claude Opus 5', - 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6', - 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5', + 'agents.claudeDesktopMapping.opus': 'Opus', + 'agents.claudeDesktopMapping.sonnet': 'Sonnet', + 'agents.claudeDesktopMapping.haiku': 'Haiku', 'agents.claudeMapping.context1m': '1M', 'agents.claudeMapping.context1mHint': '打开后按 1M 上下文配置,请确认所选上游模型支持。', 'agents.model.readingAvailable': '正在读取可用模型', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 83f60af..ed6de2c 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -2,6 +2,7 @@ import { MessageNotice } from '../appNotice'; import { useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -61,6 +62,7 @@ import { type DeepSeekHarnessLaunchOptions, } from '../services/deepSeekHarnessLaunch'; import type { ModelOption } from '../services/modelService'; +import { claudeDesktopAliasSuggestions, claudeDesktopDefaultAliases, createDefaultDesktopModels, desktopAliasNotice, desktopEntryValidation, desktopModelEntries, desktopModelId, desktopModelValidation, isClaudeDesktopModel, selectedDesktopModelEntries, type ClaudeDesktopModelMapping } from '../services/claudeDesktopModels'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionsPanel } from './CodexSessionsPanel'; import { CodexModelCatalogDialog } from './CodexModelCatalogDialog'; @@ -142,6 +144,7 @@ type PiProviderUpdateStatus = { type OAuthLoginRequiredAction = 'enable' | 'apply' | 'launch'; type ClaudeModelMappings = { + desktopModels?: ClaudeDesktopModelMapping[]; opus: string; sonnet: string; haiku: string; @@ -188,7 +191,7 @@ const createClaudeModelMappingsByClient = (): Record< ClaudeModelMappings > => ({ 'claude-code': createClaudeModelMappings(''), - 'claude-desktop': createClaudeModelMappings(''), + 'claude-desktop': { ...createClaudeModelMappings(''), desktopModels: createDefaultDesktopModels() }, }); const createClaudeBooleanByClient = (): Record => ({ @@ -450,7 +453,9 @@ type AgentModelPickerProps = { error: string; disabled: boolean; onChange: (value: string) => void; - onRefresh: () => void; + onRefresh?: () => void; + allowCustomValue?: boolean; + editable?: { label: string; placeholder: string; maxLength: number }; }; type AgentModelDropdownLayout = { @@ -468,6 +473,8 @@ function AgentModelPicker({ disabled, onChange, onRefresh, + allowCustomValue = false, + editable, }: AgentModelPickerProps) { const { t } = useI18n(); const [open, setOpen] = useState(false); @@ -476,13 +483,21 @@ function AgentModelPicker({ const [dropdownLayout, setDropdownLayout] = useState(null); const rootRef = useRef(null); const searchRef = useRef(null); - const visibleModels = useMemo(() => filterAgentModels(models, search), [models, search]); - const choices = useMemo( - () => visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })), - [visibleModels], + const valueRef = useRef(null); + const listboxId = useId(); + const visibleModels = useMemo( + () => editable && !search.trim() ? models : filterAgentModels(models, search), + [models, search, editable], ); + const choices = useMemo(() => { + const options = visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })); + if (allowCustomValue && search.trim() && !findAgentModel(models, search)) { + options.push({ name: search.trim(), alias: t('agents.model.useCustom') }); + } + return options; + }, [visibleModels, allowCustomValue, search, models, t]); const selectedModel = findAgentModel(models, value); - const selectedName = selectedModel?.name ?? ''; + const selectedName = selectedModel?.name ?? (allowCustomValue || editable ? value.trim() : ''); const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : ''; const updateDropdownLayout = useCallback(() => { @@ -541,12 +556,12 @@ function AgentModelPicker({ useEffect(() => { if (!open) return; - setSearch(''); - const selectedIndex = filterAgentModels(models, '').findIndex( + if (!editable) setSearch(''); + const selectedIndex = (editable ? visibleModels : filterAgentModels(models, '')).findIndex( (model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(), ); setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); - requestAnimationFrame(() => searchRef.current?.focus()); + if (!editable) requestAnimationFrame(() => searchRef.current?.focus()); }, [open]); useEffect(() => { @@ -556,6 +571,7 @@ function AgentModelPicker({ const choose = (name: string) => { onChange(name); setOpen(false); + if (editable) valueRef.current?.focus(); }; const moveActive = (offset: number) => { @@ -566,11 +582,13 @@ function AgentModelPicker({ const handleSearchKeyDown = (event: KeyboardEvent) => { if (event.key === 'ArrowDown') { event.preventDefault(); - moveActive(1); + if (!open) setOpen(true); + else moveActive(1); } else if (event.key === 'ArrowUp') { event.preventDefault(); - moveActive(-1); - } else if (event.key === 'Enter' && choices[activeIndex]) { + if (!open) setOpen(true); + else moveActive(-1); + } else if (event.key === 'Enter' && open && choices[activeIndex]) { event.preventDefault(); choose(choices[activeIndex].name); } else if (event.key === 'Escape') { @@ -580,12 +598,35 @@ function AgentModelPicker({ }; return ( -
- +
+ ) : + } {open ? (
-
+ {!editable ?
{search ? ( @@ -640,20 +681,22 @@ function AgentModelPicker({ ) : null} - -
+ : null} +
: null} -
- {loading && models.length === 0 ? ( +
+ {loading && models.length === 0 && choices.length === 0 ? (
{t('agents.model.fetching')}
- ) : error && models.length === 0 ? ( + ) : error && models.length === 0 && choices.length === 0 ? (
{t('agents.model.loadFailed')}{error}
) : choices.length === 0 ? (
- {search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')} - {search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')} + {editable ? {t('agents.claudeDesktopMapping.customAliasHint')} : <> + {search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')} + {search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')} + }
) : choices.map((choice, index) => { const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(); @@ -677,7 +720,7 @@ function AgentModelPicker({ })}
- {t('agents.model.count', { count: models.length })} + {t(editable ? 'agents.claudeDesktopMapping.suggestionCount' : 'agents.model.count', { count: models.length })} {error && models.length > 0 ? {t('agents.model.stale')} : null}
@@ -686,6 +729,43 @@ function AgentModelPicker({ ); } +function ClaudeDesktopHelpDialog({ onClose }: { onClose: () => void }) { + const { t } = useI18n(); + const dialogRef = useRef(null); + const titleId = useId(); + useEffect(() => { + const dialog = dialogRef.current; + const previousFocus = document.activeElement; + dialog?.showModal(); + return () => { + dialog?.close(); + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); + }; + }, []); + return ( + { event.preventDefault(); onClose(); }} + onMouseDown={(event) => { + if (event.target !== event.currentTarget) return; + const rect = event.currentTarget.getBoundingClientRect(); + if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); + }}> +
+

{t('agents.claudeDesktopMapping.help')}

+ +
+
    +
  1. {t('agents.claudeDesktopMapping.description')}
  2. +
  3. {t('agents.claudeDesktopMapping.collisionHint')}
  4. +
  5. {t('agents.claudeDesktopMapping.idHint')}
  6. +
+
+ +
+
+ ); +} + type AgentsPageProps = { embedded?: boolean; onConfigurationApplied?: () => void; @@ -753,6 +833,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [launchDirectoryDialogOpen, setLaunchDirectoryDialogOpen] = useState(false); const [codexCatalogDialogOpen, setCodexCatalogDialogOpen] = useState(false); const [harnessCatalogDialogOpen, setHarnessCatalogDialogOpen] = useState(false); + const [desktopHelpOpen, setDesktopHelpOpen] = useState(false); const [launchDirectory, setLaunchDirectory] = useState(''); const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState(null); const [launchDirectoryError, setLaunchDirectoryError] = useState(''); @@ -919,6 +1000,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setClearError(''); setClearConfirmOpen(false); setCodexCatalogDialogOpen(false); + setDesktopHelpOpen(false); setLaunchDirectoryDialogOpen(false); setLaunchDirectoryTarget(null); setLaunchDirectoryError(''); @@ -949,6 +1031,10 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP () => filterAgentModelsByAlias(models, claudeCustomMapping), [claudeCustomMapping, models], ); + const desktopEntries = claudeModelMappingsDraft.desktopModels ?? desktopModelEntries(claudeModelMappingsDraft); + const appliedDesktopEntries = activeStatus?.claudeDesktopModelMappings + ? desktopModelEntries(activeStatus.claudeDesktopModelMappings) : []; + const desktopValidation = desktopModelValidation(desktopEntries, models, appliedDesktopEntries); const loadPiProviderUpdateStatus = useCallback(async () => { const requestId = piUpdateRequestRef.current + 1; @@ -980,12 +1066,12 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP : activeStatus?.pluginVersion ?? undefined; useEffect(() => { - if (!isClaudeModelMappingClient || !selectedModel) return; + if (!isClaudeModelMappingClient || (selected !== 'claude-desktop' && !selectedModel)) return; const appliedMappings = selected === 'claude-code' ? activeStatus?.claudeCodeModelMappings : activeStatus?.claudeDesktopModelMappings; const dirty = claudeModelMappingsDirtyRef.current[selected]; - if (!dirty) { + if (!dirty && selected === 'claude-code') { const appliedModels = appliedMappings ? claudeMappingRoles .map((role) => findAgentModel(models, appliedMappings[role.key])) @@ -1007,6 +1093,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP dirty, ); const next: ClaudeModelMappings = { + ...(selected === 'claude-desktop' ? { desktopModels: dirty && source.desktopModels + ? source.desktopModels : appliedMappings ? desktopModelEntries(source) : createDefaultDesktopModels() } : {}), opus: findAgentModel(models, source.opus)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel), sonnet: findAgentModel(models, source.sonnet)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel), haiku: findAgentModel(models, source.haiku)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel), @@ -1067,9 +1155,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }); }; const claudeMappingsReady = !isClaudeModelMappingClient - || claudeMappingRoles.every((role) => + || (selected === 'claude-desktop' ? !desktopValidation : claudeMappingRoles.every((role) => Boolean(findAgentModel(models, claudeModelMappingsDraft[role.key])), - ); + )); const claudeCodeRuntimeSettingsReady = selected !== 'claude-code' || ( claudeModelMappingsDraft.maxContextTokens >= 100_000 && claudeModelMappingsDraft.maxContextTokens <= 1_000_000 @@ -1250,6 +1338,15 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const requireClaudeModelMappings = (): ClaudeModelMappings | null => { if (!isClaudeModelMappingClient) return null; + if (selected === 'claude-desktop') { + if (desktopValidation) { + setModelSelectionError(t(`agents.claudeDesktopMapping.error.${desktopValidation}`)); + return null; + } + const selectedEntries = selectedDesktopModelEntries(desktopEntries); + return { ...createClaudeModelMappings(''), sonnet: selectedEntries[0].model.trim(), + desktopModels: selectedEntries.map((entry) => ({ ...entry, model: entry.model.trim(), alias: entry.alias.trim() })) }; + } const resolved = {} as ClaudeModelMappings; for (const role of claudeMappingRoles) { const model = findAgentModel(models, claudeModelMappingsDraft[role.key]); @@ -1772,6 +1869,89 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP description={modificationDescription} />; const configurationErrorMessage = configurationError || (!nativeOauth ? modelSelectionError || modelError : ''); + const editDesktopEntries = (entries: ClaudeDesktopModelMapping[]) => { + setModelSelectionError(''); + editClaudeModelMappings((current) => ({ ...current, desktopModels: entries })); + }; + const updateDesktopEntry = (index: number, changes: Partial) => { + editDesktopEntries(desktopEntries.map((entry, i) => i === index ? { ...entry, ...changes } : entry)); + }; + const desktopModelEditor = ( +
+
+ {t('agents.claudeDesktopMapping.title')} +
+ + +
+
+ {!desktopEntries.length ?

{t('agents.claudeDesktopMapping.empty')}

: null} +
+ {desktopEntries.map((entry, index) => { + const entryError = entry.model.trim() ? desktopEntryValidation(entry) : null; + const aliasNotice = desktopAliasNotice(entry, desktopEntries, models, appliedDesktopEntries); + const isClaude = isClaudeDesktopModel(entry.model); + return ( +
+
+
+ {t('agents.claudeDesktopMapping.model')} + updateDesktopEntry(index, { model, ...(isClaudeDesktopModel(model) ? { alias: '' } : {}) })} + onRefresh={refreshModels} /> +
+
+ {t('agents.claudeDesktopMapping.alias')} + !desktopEntries.some((other, i) => i !== index && other.model.trim() && desktopModelId(other).toLowerCase() === alias)) + .map((name) => ({ name, alias: t('agents.claudeDesktopMapping.suggestedAlias') }))} + value={entry.alias} loading={false} error="" disabled={busy || loading} + editable={{ label: t('agents.claudeDesktopMapping.alias'), + placeholder: t(isClaude ? 'agents.claudeDesktopMapping.claudeDefault' : 'agents.claudeDesktopMapping.noRename'), maxLength: 128 }} + onChange={(alias) => updateDesktopEntry(index, { alias })} /> +
+
+ + +
+
+ {entryError && hasPendingChanges && !modelLoading + ?

{t(`agents.claudeDesktopMapping.error.${entryError}`)}

+ : aliasNotice === 'aliasExists' + ?

{t( + claudeDesktopDefaultAliases.includes(entry.alias.trim().toLowerCase()) + ? 'agents.claudeDesktopMapping.defaultAliasExists' : 'agents.claudeDesktopMapping.error.aliasExists', + { alias: entry.alias.trim() }, + )}

+ : aliasNotice === 'sameModel' + ?

{t('agents.claudeDesktopMapping.sameModel')}

+ : isClaude + ?

{t('agents.claudeDesktopMapping.claudeDefault')}

+ : null} +
+ ); + })} +
+ {desktopValidation === 'duplicate' && !modelLoading && hasPendingChanges + ?

{t(`agents.claudeDesktopMapping.error.${desktopValidation}`)}

: null} +
+ ); + return (
@@ -1870,8 +2050,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP - {selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ?

{t('agents.backup.mappingRequired')}

: null} -
+ {selected === 'claude-desktop' ? desktopModelEditor :
{codexCatalogButton}{harnessCatalogButton} -
+
} {isDeepSeekHarnessClient ?

{t('agents.harness.defaultHint')}

: null} @@ -2057,7 +2236,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
) : null} - {isClaudeModelMappingClient ? ( + {selected === 'claude-desktop' ? desktopModelEditor : null} + {selected === 'claude-code' ? (
@@ -2195,7 +2375,6 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{configurationFeedback} - {selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ?

{t('agents.backup.mappingRequired')}

: null}
{closeConfigurationButton}
+ {desktopHelpOpen ? setDesktopHelpOpen(false)} /> : null} {backupsOpen ? setBackupsOpen(false)} onRestored={async () => { clearPendingChanges(); if (selected === 'claude-code' || selected === 'claude-desktop') claudeModelMappingsDirtyRef.current[selected] = false; diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index 000330e..9aa1d63 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -18,6 +18,7 @@ export type AgentConfigurationAction = 'apply' | 'update' | 'close'; export type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop'; export type AgentModelMappings = { + desktopModels?: import('./claudeDesktopModels').ClaudeDesktopModelMapping[]; opus: string; sonnet: string; haiku: string; @@ -50,7 +51,15 @@ export const sameAgentModel = (left: string, right: string) => ( export const sameAgentModelMappings = ( left: AgentModelMappings, right: AgentModelMappings, -) => sameAgentModel(left.opus, right.opus) +) => (left.desktopModels !== undefined || right.desktopModels !== undefined) + ? left.desktopModels !== undefined && right.desktopModels !== undefined + && left.desktopModels.length === right.desktopModels.length + && left.desktopModels.every((entry, index) => { + const other = right.desktopModels![index]; + return sameAgentModel(entry.model, other.model) && entry.alias.trim() === other.alias.trim() + && Boolean(entry.context1m) === Boolean(other.context1m); + }) + : sameAgentModel(left.opus, right.opus) && sameAgentModel(left.sonnet, right.sonnet) && sameAgentModel(left.haiku, right.haiku) && Boolean(left.opus1m) === Boolean(right.opus1m) diff --git a/src/services/claudeDesktopModelRules.json b/src/services/claudeDesktopModelRules.json new file mode 100644 index 0000000..ffe8ca0 --- /dev/null +++ b/src/services/claudeDesktopModelRules.json @@ -0,0 +1,3 @@ +{ + "otherModelFamilyPattern": "ark-code|astron|command-r|deepseek|doubao|gemini|gemma|glm|gpt|grok|hermes|hy3|kimi|lfm|\\bling\\b|llama|longcat|mimo|minimax|mistral|mixtral|moonshot|nemotron|openai|phi-|qianfan|qwen|tc-code|\\bunic\\b|yi-|stepfun|step-3|seed-|bytedance|hunyuan|granite|amazon\\.nova|nova-|devstral|ministral|ernie|codex|arcee|trinity|abab|phi\\d|\\bk2\\.|\\bm2\\.|jamba|arctic|solar|mercury|zamba|kat-coder|\\bds-|dpsk" +} diff --git a/src/services/claudeDesktopModels.ts b/src/services/claudeDesktopModels.ts new file mode 100644 index 0000000..7ea6732 --- /dev/null +++ b/src/services/claudeDesktopModels.ts @@ -0,0 +1,99 @@ +import type { AgentModelMappings } from './agentConfigurationDraft'; +import type { ModelOption } from './modelService'; +import claudeDesktopModelRules from './claudeDesktopModelRules.json'; + +export type ClaudeDesktopModelMapping = { model: string; alias: string; context1m: boolean }; + +export const desktopModelId = (entry: ClaudeDesktopModelMapping) => entry.alias.trim() || entry.model.trim(); + +export const claudeDesktopDefaultAliases = ['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5']; + +export const createDefaultDesktopModels = (): ClaudeDesktopModelMapping[] => + claudeDesktopDefaultAliases.map((alias) => ({ model: '', alias, context1m: false })); + +export const selectedDesktopModelEntries = (entries: ClaudeDesktopModelMapping[]) => + entries.filter((entry) => entry.model.trim()); + +export const claudeDesktopAliasSuggestions = [ + 'claude-fable-5-1', 'claude-opus-5', 'claude-sonnet-5', 'claude-fable-5', + 'claude-opus-4-8', 'claude-opus-4-7', + 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5', + 'claude-opus-4-5', 'claude-sonnet-4-5', 'claude-haiku-4-5-20251001', +]; + +export const validClaudeDesktopAlias = (alias: string) => alias.trim().length <= 128 + && /^claude-[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(alias.trim()) + && isClaudeDesktopModel(alias); + +const modelKey = (id: string) => id.trim().toLowerCase(); + +const otherModelFamily = new RegExp(claudeDesktopModelRules.otherModelFamilyPattern); + +export function isClaudeDesktopModel(id: string) { + const name = modelKey(id); + return !/\s/.test(name) && !otherModelFamily.test(name) + && (name.startsWith('claude-') || /\banthropic[/.]/.test(name) + || /^(opus|sonnet|haiku|fable|mythos)(-[\d.]+)?$/.test(name)); +} + +export function desktopEntryValidation(entry: ClaudeDesktopModelMapping) { + const model = entry.model.trim(); + const alias = entry.alias.trim(); + if (!model) return 'model' as const; + if (new TextEncoder().encode(model).length > 240 || /[\s\u0000-\u001f\u007f]/.test(model)) return 'modelFormat' as const; + if (alias && modelKey(alias) !== modelKey(model) && !validClaudeDesktopAlias(alias)) return 'alias' as const; + return null; +} + +export function desktopAliasNotice( + entry: ClaudeDesktopModelMapping, + entries: ClaudeDesktopModelMapping[], + models: ModelOption[], + appliedEntries: ClaudeDesktopModelMapping[] = [], +) { + const alias = modelKey(entry.alias); + if (!alias) return null; + if (alias === modelKey(entry.model)) return 'sameModel' as const; + const appliedAlias = appliedEntries.some((applied) => modelKey(applied.alias) === alias + && applied.model.trim() && modelKey(applied.model) !== alias); + if (models.some((model) => modelKey(model.name) === alias && (!model.isAlias || !appliedAlias)) + || entries.some((other) => modelKey(other.model) === alias)) return 'aliasExists' as const; + return null; +} + +export function desktopModelEntries(mappings: AgentModelMappings): ClaudeDesktopModelMapping[] { + if (mappings.desktopModels) return mappings.desktopModels.map((entry) => { + const model = entry.model.trim() || entry.alias.trim(); + return { ...entry, model, alias: modelKey(entry.alias) === modelKey(model) ? '' : entry.alias }; + }); + const aliases = ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5']; + const entries = (['opus', 'sonnet', 'haiku'] as const).flatMap((role, index) => { + const model = mappings[role]; + if (!model) return []; + return [{ model, alias: isClaudeDesktopModel(model) ? '' : aliases[index], context1m: Boolean(mappings[`${role}1m`]) }]; + }); + const unique: ClaudeDesktopModelMapping[] = []; + for (const entry of entries) { + const existing = unique.find((other) => desktopModelId(other) === desktopModelId(entry)); + if (existing) existing.context1m ||= entry.context1m; + else unique.push(entry); + } + return unique; +} + +export function desktopModelValidation( + entries: ClaudeDesktopModelMapping[], + models: ModelOption[], + appliedEntries: ClaudeDesktopModelMapping[] = [], +) { + const selectedEntries = selectedDesktopModelEntries(entries); + if (!selectedEntries.length) return 'empty' as const; + for (const entry of selectedEntries) { + const error = desktopEntryValidation(entry); + if (error) return error; + } + const aliases = selectedEntries.map((entry) => desktopModelId(entry).toLowerCase()); + if (new Set(aliases).size !== aliases.length) return 'duplicate' as const; + if (selectedEntries.some((entry) => desktopAliasNotice(entry, selectedEntries, models, appliedEntries) === 'aliasExists')) return 'aliasExists' as const; + return null; +} diff --git a/src/styles.css b/src/styles.css index 20467fc..d4e3b64 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6827,6 +6827,98 @@ textarea:focus-visible { box-shadow: 0 1px 2px rgba(45, 42, 38, 0.04); } +.agent-desktop-models { + grid-column: 1 / -1; +} + +.agent-desktop-models > .agent-section-heading button { + width: auto; + margin-top: 0; + flex-shrink: 0; + white-space: nowrap; +} + +.agent-desktop-model-list { + display: grid; + gap: 8px; +} + +.agent-desktop-model-row { + padding: 10px; + border-radius: 7px; + box-shadow: none; +} + +.agent-desktop-model-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + align-items: end; + gap: 12px; +} + +.agent-desktop-model-field { + display: grid; + min-width: 0; + gap: 5px; + font-size: var(--font-size-label); + color: var(--theme-5f5a53); +} + +.agent-desktop-model-actions { + height: 44px; +} + +.agent-desktop-model-notice { + margin: 0; + color: var(--theme-8a847c); + font-size: var(--font-size-caption); + line-height: 1.5; +} + +.agent-desktop-help-dialog { + grid-template-rows: auto; + align-content: start; + width: min(600px, calc(100vw - 32px)); + height: fit-content; + max-height: calc(100vh - 32px); + margin: auto; + overflow: auto; +} + +.agent-desktop-help-dialog::backdrop { + background: rgba(45, 42, 38, 0.42); + backdrop-filter: blur(3px); +} + +.agent-desktop-help-list { + display: grid; + gap: 16px; + margin: 0; + padding-left: 22px; + color: var(--theme-5f5a53); + font-size: var(--font-size-body); + line-height: 1.6; + overflow-wrap: anywhere; +} + +.agent-desktop-help-actions { + display: flex; + justify-content: flex-end; +} + +@media (max-width: 700px) { + .agent-desktop-models > .agent-section-heading { flex-wrap: wrap; } + .agent-desktop-models > .agent-section-heading > .agent-section-heading-actions { + width: 100%; + flex-wrap: wrap; + } +} + +@media (max-width: 520px) { + .agent-desktop-model-fields { grid-template-columns: minmax(0, 1fr); gap: 8px; } + .agent-desktop-model-actions { height: 34px; justify-self: end; } +} + .agent-claude-desktop-mapping-row:has(.agent-claude-context-toggle input:checked) { border-color: rgba(128, 105, 173, 0.48); box-shadow: 0 0 0 2px rgba(128, 105, 173, 0.08); @@ -6977,6 +7069,43 @@ textarea:focus-visible { gap: 2px; } +.agent-model-editable-trigger input { + width: 100%; + min-width: 0; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: var(--font-size-body); +} + +.agent-model-editable-trigger:focus-within { + border-color: var(--theme-8069ad); + box-shadow: 0 0 0 2px rgba(128, 105, 173, 0.14); +} + +.agent-model-editable-trigger:has(input:disabled) { + cursor: not-allowed; + background: var(--theme-f4f1ea); + opacity: 0.62; +} + +.agent-model-editable-trigger > button { + width: 20px; + height: 24px; + min-height: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--theme-8a847c); +} + +.agent-model-picker.open .agent-model-editable-trigger > button > svg { + transform: rotate(180deg); +} + .agent-model-trigger strong, .agent-model-trigger small { overflow: hidden; @@ -7016,6 +7145,10 @@ textarea:focus-visible { box-shadow: 0 14px 34px rgba(45, 42, 38, 0.2); } +.agent-model-dropdown-editable { + grid-template-rows: minmax(0, 1fr) 28px; +} + .agent-model-search { display: grid; grid-template-columns: 17px minmax(0, 1fr) auto auto; diff --git a/tests/agent-backups-ui.cjs b/tests/agent-backups-ui.cjs index f646486..bcfb71d 100644 --- a/tests/agent-backups-ui.cjs +++ b/tests/agent-backups-ui.cjs @@ -65,7 +65,7 @@ const assert = require('node:assert/strict'); assert.ok(await page.locator('.agent-backup-modal').evaluate(el => el.scrollWidth <= el.clientWidth)); await page.screenshot({ path: process.env.TEMP + '/agent-backups-narrow.png', fullPage: true }); await open('client=claude-desktop'); - await page.getByText('没有可靠的 Claude Desktop 模型映射,请重新选择模型。', { exact: true }).waitFor(); + await page.locator('.agent-desktop-model-row').nth(2).waitFor(); assert.ok(await page.getByRole('button', { name: '更新配置', exact: true }).isDisabled()); for (const query of ['client=pi', 'embedded&client=pi']) { await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?' + query); diff --git a/tests/agent-management-ui.cjs b/tests/agent-management-ui.cjs index ad4ad24..787946d 100644 --- a/tests/agent-management-ui.cjs +++ b/tests/agent-management-ui.cjs @@ -17,7 +17,7 @@ const path = require('node:path'); await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?reset-selections&' + query); await page.waitForFunction(() => { const refresh = document.querySelector('.agent-header-actions button'); - return refresh && !refresh.disabled && document.querySelector('.agent-model-trigger') + return refresh && !refresh.disabled && document.querySelector('.agent-model-trigger, .agent-desktop-models') && window.fixtureCalls.some(call => call.cmd === 'get_agent_models'); }); }; diff --git a/tests/agent-pending-ui.cjs b/tests/agent-pending-ui.cjs index 5c734e2..fbbf0be 100644 --- a/tests/agent-pending-ui.cjs +++ b/tests/agent-pending-ui.cjs @@ -16,7 +16,8 @@ const assert = require('node:assert/strict'); }; const ready = () => page.waitForFunction(() => { const button = document.querySelector('.agent-model-trigger'); - return button && !button.disabled && !button.textContent.includes('加载'); + return button ? !button.disabled && !button.textContent.includes('加载') + : document.querySelector('.agent-desktop-models') && window.fixtureCalls.some(call => call.cmd === 'get_agent_models'); }); const open = async query => { await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?reset-selections&' + query); diff --git a/tests/claude-desktop-models-ui.cjs b/tests/claude-desktop-models-ui.cjs new file mode 100644 index 0000000..85be64d --- /dev/null +++ b/tests/claude-desktop-models-ui.cjs @@ -0,0 +1,227 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }); + page.setDefaultTimeout(10000); + const errors = []; page.on('pageerror', error => errors.push(String(error))); + const open = async query => { + await page.goto('http://127.0.0.1:1421/tests/fixtures/agent-backups.html?client=claude-desktop&' + query, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: '添加模型', exact: true }).waitFor(); + }; + const rows = () => page.locator('.agent-desktop-model-row'); + const originalPicker = index => rows().nth(index).locator('button.agent-model-trigger'); + const originalName = index => originalPicker(index).locator('strong').innerText(); + const idInput = index => rows().nth(index).getByRole('combobox', { name: '别名(Claude系列模型可留空)', exact: true }); + const fillAlias = async (index, alias) => { + await idInput(index).fill(alias); + await idInput(index).press('Escape'); + }; + const chooseOriginal = async (index, model) => { + await originalPicker(index).click(); + await rows().nth(index).locator('.agent-model-search input').fill(model); + await page.getByRole('option').filter({ has: page.getByText(model, { exact: true }) }).click(); + }; + const save = () => page.getByRole('button', { name: '更新配置', exact: true }); + const add = async (model, alias) => { + await page.getByRole('button', { name: '添加模型', exact: true }).click(); + const index = await rows().count() - 1; + if (model) await chooseOriginal(index, model); + await fillAlias(index, alias); + }; + for (const mode of ['', 'embedded']) { + await open(mode); + assert.equal(await rows().count(), 3); + assert.deepEqual(await Promise.all([0, 1, 2].map(index => idInput(index).inputValue())), + ['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5']); + assert.ok(await save().isDisabled()); + const addButton = page.getByRole('button', { name: '添加模型', exact: true }); + assert.equal(await addButton.locator('svg').count(), 0); + assert.ok(await addButton.evaluate(button => button.classList.contains('primary-button'))); + assert.ok((await rows().first().boundingBox()).height < 100); + assert.doesNotMatch(await page.locator('.agent-desktop-models').innerText(), /先拒绝|左侧选择或输入/); + await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-defaults-${mode || 'full'}.png`), fullPage: true }); + const help = page.getByRole('button', { name: '使用说明', exact: true }); + await help.click(); + const dialog = page.getByRole('dialog', { name: '使用说明', exact: true }); + await dialog.waitFor(); + assert.ok((await dialog.boundingBox()).height < 600); + assert.equal(await dialog.getByRole('listitem').count(), 3); + assert.match(await dialog.innerText(), /只允许使用 Claude.*无需映射/s); + assert.match(await dialog.innerText(), /轮询调度.*期望的模型/s); + assert.match(await dialog.innerText(), /claude-opus-\*.*claude-sonnet-\*.*claude-haiku-\*.*不能包含.*gpt.*grok.*gemini.*deepseek/s); + await page.screenshot({ path: path.join(process.env.TEMP, 'claude-desktop-help.png'), fullPage: true }); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'detached' }); + assert.ok(await help.evaluate(button => button === document.activeElement)); + await help.click(); + await dialog.locator('.agent-desktop-help-actions').getByRole('button', { name: '关闭', exact: true }).click(); + const selectedIndex = mode ? 1 : 0; + const selectedAlias = await idInput(selectedIndex).inputValue(); + await chooseOriginal(selectedIndex, 'gpt-two'); + assert.equal(await page.getByText('请选择或填写原模型。', { exact: true }).count(), 0); + assert.ok(await save().isEnabled()); + await page.evaluate(() => window.fixtureRemount()); + await rows().nth(2).waitFor(); + assert.equal(await originalName(selectedIndex), 'gpt-two'); + assert.equal(await originalName(selectedIndex === 0 ? 1 : 0), '选择模型'); + assert.ok(await save().isEnabled()); + await save().click(); + await page.getByText('配置已更新。', { exact: true }).waitFor(); + const partialCall = await page.evaluate(() => window.fixtureCalls.filter(c => c.cmd === 'update_agent_config').at(-1)); + assert.equal(partialCall.args.model, 'gpt-two'); + assert.deepEqual(partialCall.args.claudeDesktopModelMappings.desktopModels, + [{ model: 'gpt-two', alias: selectedAlias, context1m: false }]); + await page.evaluate(() => window.fixtureRemount()); + await rows().first().waitFor(); + assert.equal(await rows().count(), 1); + await open(mode); + for (let i = 0; i < 3; i++) await rows().last().getByRole('button', { name: '移除模型' }).click(); + await page.evaluate(() => window.fixtureRemount()); + await addButton.waitFor(); + assert.equal(await rows().count(), 0); + await add('gpt-two', 'my-model'); + assert.ok(await save().isDisabled()); + assert.equal(await page.locator('.agent-desktop-models select').count(), 0); + assert.equal(await page.locator('.agent-desktop-models datalist').count(), 0); + assert.equal(await page.locator('.agent-desktop-models details').count(), 0); + assert.doesNotMatch(await page.locator('.agent-desktop-models').innerText(), /映射|CPA/); + const left = await originalPicker(0).boundingBox(); + const right = await rows().nth(0).locator('.agent-model-editable-trigger').boundingBox(); + assert.ok(left.x + left.width <= right.x && left.y === right.y); + assert.equal(left.height, right.height); + assert.equal(await idInput(0).getAttribute('placeholder'), '不重名'); + await originalPicker(0).click(); + const originals = await page.locator('.agent-model-option strong').allTextContents(); + assert.deepEqual(originals, ['gpt-one', 'gpt-two']); + assert.equal(await page.getByRole('option', { selected: true }).locator('strong').innerText(), 'gpt-two'); + await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-original-dropdown-${mode || 'full'}.png`), fullPage: true }); + await rows().nth(0).locator('.agent-model-search input').press('Escape'); + await idInput(0).click(); + const suggestions = await page.locator('.agent-model-option strong').allTextContents(); + assert.ok(suggestions.includes('claude-sonnet-4-6')); + assert.ok(suggestions.every(id => !id.includes('cpa'))); + await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-alias-dropdown-${mode || 'full'}.png`), fullPage: true }); + await page.getByRole('option').filter({ has: page.getByText('claude-sonnet-4-6', { exact: true }) }).click(); + assert.equal(await idInput(0).inputValue(), 'claude-sonnet-4-6'); + assert.ok(await save().isEnabled()); + await add('gpt-one', 'claude-sonnet-4-6'); + await page.getByText('别名或原模型 ID 重复,请为每一行使用不同的 ID。', { exact: true }).waitFor(); + assert.ok(await save().isDisabled()); + await fillAlias(1, 'claude-opus-4-6'); + await rows().nth(1).getByRole('checkbox').check(); + await add('', 'claude-haiku-4-5'); + assert.equal(await originalName(2), '选择模型'); + assert.ok(await save().isEnabled()); + assert.equal(await rows().nth(2).getByText('请选择或填写原模型。', { exact: true }).count(), 0); + await rows().last().getByRole('button', { name: '移除模型' }).click(); + await fillAlias(0, ''); + assert.ok(await save().isEnabled()); + await page.getByRole('tab', { name: '配置管理', exact: true }).click(); + await page.getByRole('tab', { name: '基础配置', exact: true }).click(); + assert.equal(await rows().count(), 2); + await save().click(); + await page.waitForFunction(() => window.fixtureCalls.some(c => c.cmd === 'update_agent_config')); + const call = await page.evaluate(() => window.fixtureCalls.filter(c => c.cmd === 'update_agent_config').at(-1)); + assert.equal(call.args.model, 'gpt-two'); + assert.deepEqual(call.args.claudeDesktopModelMappings.desktopModels, [ + { model: 'gpt-two', alias: '', context1m: false }, + { model: 'gpt-one', alias: 'claude-opus-4-6', context1m: true }, + ]); + await page.getByText('配置已更新。', { exact: true }).waitFor(); + await page.evaluate(() => window.fixtureRemount()); + await rows().nth(1).waitFor(); + assert.equal(await idInput(0).inputValue(), ''); + assert.equal(await originalName(0), 'gpt-two'); + await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-models-${mode || 'full'}.png`), fullPage: true }); + for (let i = 0; i < 3; i++) await add(`claude-custom-${i}`, ''); + assert.equal(await rows().count(), 5); + assert.ok(await save().isEnabled()); + for (let i = 0; i < 3; i++) await rows().last().getByRole('button', { name: '移除模型' }).click(); + await rows().nth(1).getByRole('button', { name: '移除模型' }).click(); + assert.ok(await save().isEnabled()); + await rows().first().getByRole('button', { name: '移除模型' }).click(); + assert.ok(await save().isDisabled()); + } + for (const query of ['claude-models', 'claude-alias-models']) { + await open(query); + const opusWarning = rows().nth(0).getByText('默认别名「claude-opus-5」已存在于模型列表中,请切换其他别名,避免调用到其他模型。', { exact: true }); + await opusWarning.waitFor(); + await rows().nth(1).getByText('默认别名「claude-sonnet-5」已存在于模型列表中,请切换其他别名,避免调用到其他模型。', { exact: true }).waitFor(); + await chooseOriginal(2, 'gpt-one'); + assert.ok(await save().isEnabled()); + assert.equal(await page.getByText('请选择或填写原模型。', { exact: true }).count(), 0); + await chooseOriginal(0, 'gpt-one'); + await opusWarning.waitFor(); + assert.ok(await save().isDisabled()); + await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-default-conflict-${query}.png`), fullPage: true }); + await fillAlias(0, 'claude-opus-personal'); + assert.equal(await opusWarning.count(), 0); + assert.ok(await save().isEnabled()); + } + await open('saved-desktop-alias'); + assert.equal(await rows().count(), 1); + assert.equal(await idInput(0).inputValue(), 'claude-opus-5'); + assert.equal(await rows().first().getByRole('status').count(), 0); + await rows().first().getByRole('checkbox').check(); + assert.ok(await save().isEnabled()); + await save().click(); + await page.getByText('配置已更新。', { exact: true }).waitFor(); + await open('claude-models'); + for (let i = 0; i < 2; i++) await rows().last().getByRole('button', { name: '移除模型' }).click(); + await chooseOriginal(0, 'claude-opus-5'); + assert.equal(await idInput(0).inputValue(), ''); + assert.equal(await idInput(0).getAttribute('placeholder'), 'Claude系列模型默认不映射'); + await rows().first().getByText('Claude系列模型默认不映射', { exact: true }).waitFor(); + assert.ok(await save().isEnabled()); + await fillAlias(0, ' CLAUDE-OPUS-5 '); + await rows().first().getByText('别名与原模型相同,无需重复设置,将直接使用原模型 ID。', { exact: true }).waitFor(); + assert.ok(await save().isEnabled()); + await page.screenshot({ path: path.join(process.env.TEMP, 'claude-desktop-same-model-notice.png'), fullPage: true }); + await fillAlias(0, 'claude-sonnet-5'); + await rows().first().getByText('默认别名「claude-sonnet-5」已存在于模型列表中,请切换其他别名,避免调用到其他模型。', { exact: true }).waitFor(); + assert.ok(await save().isDisabled()); + await fillAlias(0, 'claude-personal-model'); + await chooseOriginal(0, 'manual-model-not-in-list'); + assert.ok(await save().isEnabled()); + assert.equal(await rows().first().getByText('Claude系列模型默认不映射', { exact: true }).count(), 0); + await chooseOriginal(0, 'two words'); + await rows().first().getByText('原模型 ID 格式无效,请去掉空白字符或缩短名称。', { exact: true }).waitFor(); + assert.ok(await save().isDisabled()); + await chooseOriginal(0, 'manual-model-not-in-list'); + await save().click(); + await page.getByText('配置已更新。', { exact: true }).waitFor(); + const manualCall = await page.evaluate(() => window.fixtureCalls.filter(c => c.cmd === 'update_agent_config').at(-1)); + assert.deepEqual(manualCall.args.claudeDesktopModelMappings.desktopModels, + [{ model: 'manual-model-not-in-list', alias: 'claude-personal-model', context1m: false }]); + await open('legacy-desktop-direct'); + assert.equal(await rows().count(), 1); + assert.equal(await originalName(0), 'claude-sonnet-custom-7'); + assert.equal(await idInput(0).inputValue(), ''); + await open('legacy-desktop'); + assert.equal(await rows().count(), 3); + assert.equal(await idInput(0).inputValue(), 'claude-opus-4-6'); + assert.ok(await rows().first().getByRole('checkbox').isChecked()); + assert.ok(await save().isEnabled()); + for (const width of [640, 360]) { + await page.setViewportSize({ width, height: 900 }); + assert.ok(await page.locator('.agent-desktop-models').evaluate(el => el.scrollWidth <= el.clientWidth)); + const section = await page.locator('.agent-desktop-models').boundingBox(); + for (const name of ['使用说明', '添加模型']) { + const button = await page.getByRole('button', { name, exact: true }).boundingBox(); + assert.ok(button.x >= section.x && button.x + button.width <= section.x + section.width); + } + } + await page.screenshot({ path: path.join(process.env.TEMP, 'claude-desktop-models-narrow.png'), fullPage: true }); + await open('no-models'); + for (let i = 0; i < 3; i++) await rows().last().getByRole('button', { name: '移除模型' }).click(); + await add('claude-sonnet-custom-7', ''); + assert.ok(await save().isEnabled()); + await save().click(); + await page.getByText('配置已更新。', { exact: true }).waitFor(); + assert.deepEqual(errors, []); + console.log('PASS: shared original model picker, editable alias suggestions, three-point help, custom IDs, save/remount, legacy migration and narrow layout'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exit(1); }); diff --git a/tests/claudeDesktopModels.test.ts b/tests/claudeDesktopModels.test.ts new file mode 100644 index 0000000..0eb8bc4 --- /dev/null +++ b/tests/claudeDesktopModels.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'bun:test'; +import { claudeDesktopAliasSuggestions, createDefaultDesktopModels, desktopAliasNotice, desktopModelEntries, desktopModelValidation, isClaudeDesktopModel, selectedDesktopModelEntries, validClaudeDesktopAlias } from '../src/services/claudeDesktopModels'; +import { sameAgentModelMappings } from '../src/services/agentConfigurationDraft'; + +const legacy = { opus: 'gpt-one', sonnet: 'gpt-two', haiku: 'claude-haiku-4-5', opus1m: true }; +const models = [{ name: 'gpt-one' }, { name: 'gpt-two' }]; +const entry = { model: 'gpt-one', alias: 'claude-sonnet-4-6', context1m: false }; + +describe('Claude Desktop custom models', () => { + test('migrates old slots without filling an empty configuration', () => { + expect(desktopModelEntries(legacy)).toEqual([ + { model: 'gpt-one', alias: 'claude-opus-4-6', context1m: true }, + { model: 'gpt-two', alias: 'claude-sonnet-4-6', context1m: false }, + { model: 'claude-haiku-4-5', alias: '', context1m: false }, + ]); + expect(desktopModelEntries({ opus: '', sonnet: '', haiku: '' })).toEqual([]); + expect(desktopModelEntries({ ...legacy, desktopModels: [] })).toEqual([]); + expect(claudeDesktopAliasSuggestions.some((id) => id.includes('cpa'))).toBeFalse(); + }); + + test('an empty alias keeps the original ID, even outside the available model list', () => { + expect(desktopModelValidation([{ ...entry, alias: '' }], [])).toBeNull(); + expect(desktopModelValidation([{ ...entry, alias: ' ' }], [])).toBeNull(); + expect(desktopModelValidation([{ ...entry, model: '' }], models)).toBe('empty'); + expect(desktopModelValidation([{ ...entry, model: ' ', alias: '' }], [])).toBe('empty'); + expect(desktopModelValidation([{ ...entry, model: 'two words', alias: '' }], [])).toBe('modelFormat'); + expect(desktopModelValidation([{ ...entry, alias: '' }, entry], models)).toBeNull(); + expect(desktopModelValidation([{ ...entry, model: entry.alias, alias: '' }, entry], models)).toBe('duplicate'); + }); + + test('only selected originals participate in validation and configuration', () => { + const defaults = createDefaultDesktopModels(); + expect(desktopModelValidation(defaults, models)).toBe('empty'); + const partial = defaults.map((row, index) => index === 1 ? { ...row, model: 'gpt-one' } : row); + expect(desktopModelValidation(partial, models)).toBeNull(); + expect(selectedDesktopModelEntries(partial)).toEqual([partial[1]]); + const unused = { ...entry, model: ' ' }; + expect(desktopModelValidation([unused, entry], models)).toBeNull(); + const available = [...models, { name: defaults[0].alias }]; + expect(desktopAliasNotice(partial[0], partial, available)).toBe('aliasExists'); + expect(desktopModelValidation(partial, available)).toBeNull(); + expect(desktopModelValidation([{ ...partial[0], model: 'gpt-two' }, partial[1]], available)).toBe('aliasExists'); + }); + + test('migrates saved alias-only and unchanged-ID entries to an original model with no alias', () => { + const direct = { model: 'claude-sonnet-4-6', alias: '', context1m: false }; + expect(desktopModelEntries({ ...legacy, desktopModels: [{ ...entry, model: '' }] })).toEqual([direct]); + expect(desktopModelEntries({ ...legacy, desktopModels: [{ ...direct, alias: direct.model }] })).toEqual([direct]); + expect(desktopModelEntries({ ...legacy, desktopModels: [direct, entry] })).toEqual([direct, entry]); + }); + + test('accepts custom source IDs outside the loaded list and requires unique compatible aliases', () => { + expect(desktopModelValidation([entry], models)).toBeNull(); + expect(desktopModelValidation([], models)).toBe('empty'); + expect(desktopModelValidation([{ ...entry, model: 'manual-model' }], models)).toBeNull(); + expect(desktopModelValidation([entry], [])).toBeNull(); + expect(desktopModelValidation([{ ...entry, alias: 'gpt-test' }], models)).toBe('alias'); + expect(desktopModelValidation([entry, { ...entry, model: 'gpt-two', alias: ` ${entry.alias} ` }], models)).toBe('duplicate'); + expect(desktopModelValidation(Array.from({ length: 5 }, (_, i) => ({ ...entry, alias: `claude-sonnet-4-6-${i}` })), models)).toBeNull(); + for (const alias of ['claude-', 'claude-FOO', 'claude-a/b', 'claude-a..b', 'claude-a-']) expect(validClaudeDesktopAlias(alias)).toBeFalse(); + }); + + test('detects Claude model IDs without accepting other-family names with a Claude prefix', () => { + for (const id of ['claude-opus-5', ' CLAUDE-SONNET-4-6 ', 'anthropic/claude-fable-5.1', + 'us.anthropic.claude-sonnet-4-6-v1:0', 'sonnet', 'haiku-4.5']) expect(isClaudeDesktopModel(id)).toBeTrue(); + for (const id of ['', 'gpt-one', 'claude-grok-4.6', 'claude-gemini', 'claude- two']) expect(isClaudeDesktopModel(id)).toBeFalse(); + }); + + test('rejects other model families in aliases while keeping ordinary custom suffixes', () => { + for (const alias of ['claude-grok-4.6', 'claude-opus-gpt-5', 'claude-gemini', + 'claude-deepseek', 'claude-opus-phi4', 'claude-opus-k2.5', 'claude-m2.1', + 'claude-ling', 'claude-unic', 'claude-ds-test']) { + expect(validClaudeDesktopAlias(alias)).toBeFalse(); + expect(desktopModelValidation([{ ...entry, alias }], models)).toBe('alias'); + } + for (const alias of [...claudeDesktopAliasSuggestions, 'claude-opus-personal', + 'claude-linguist', 'claude-unicorn', 'claude-ranking2.1']) { + expect(validClaudeDesktopAlias(alias)).toBeTrue(); + } + }); + + test('treats an alias matching its own original as a notice, and another original as a conflict', () => { + const direct = { ...entry, model: 'claude-opus-5', alias: ' CLAUDE-OPUS-5 ' }; + expect(desktopAliasNotice(direct, [direct], [])).toBe('sameModel'); + expect(desktopModelValidation([direct], [])).toBeNull(); + const renamed = { ...entry, alias: 'claude-opus-5' }; + const available = [...models, { name: 'CLAUDE-OPUS-5' }]; + expect(desktopAliasNotice(renamed, [renamed], available)).toBe('aliasExists'); + expect(desktopModelValidation([renamed], available)).toBe('aliasExists'); + expect(desktopAliasNotice(renamed, [renamed, { ...direct, alias: 'claude-haiku-4-5' }], models)).toBe('aliasExists'); + expect(desktopAliasNotice(renamed, [renamed], [{ name: 'claude-opus-5', isAlias: true }])).toBe('aliasExists'); + }); + + test('allows existing Desktop aliases but still warns about unrelated aliases or real models', () => { + const configured = { ...entry, alias: 'claude-opus-5' }; + const available = [...models, { name: 'claude-opus-5', isAlias: true }]; + expect(desktopAliasNotice(configured, [configured], available, [configured])).toBeNull(); + expect(desktopModelValidation([configured], available, [configured])).toBeNull(); + expect(desktopModelValidation([configured], available, [])).toBe('aliasExists'); + expect(desktopModelValidation([configured], [{ name: 'claude-opus-5', isAlias: false }], [configured])).toBe('aliasExists'); + }); + + test('tracks alias edits, row removal and context changes independently of legacy fields', () => { + const before = { ...legacy, desktopModels: [entry] }; + expect(sameAgentModelMappings(before, { ...before, opus: 'ignored' })).toBeTrue(); + expect(sameAgentModelMappings(before, { ...before, desktopModels: [{ ...entry, alias: 'claude-opus-4-6' }] })).toBeFalse(); + expect(sameAgentModelMappings(before, { ...before, desktopModels: [{ ...entry, context1m: true }] })).toBeFalse(); + expect(sameAgentModelMappings(before, { ...before, desktopModels: [] })).toBeFalse(); + }); +}); diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx index 2910b63..47a3f75 100644 --- a/tests/fixtures/agent-backups.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -13,6 +13,9 @@ const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','herme let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one'; let nativeOauth=params.has('native-oauth'); let nativeSwitchCount=0; let codexClosed=false; let closeCount=0; let currentOauth=false; const currentMappings:Record={}; +if(params.has('legacy-desktop'))currentMappings['claude-desktop']={opus:'gpt-one',sonnet:'gpt-two',haiku:'gpt-one',opus1m:true}; +if(params.has('legacy-desktop-direct'))currentMappings['claude-desktop']={opus:'',sonnet:'claude-sonnet-custom-7',haiku:'',desktopModels:[{model:'',alias:'claude-sonnet-custom-7',context1m:false}]}; +if(params.has('saved-desktop-alias'))currentMappings['claude-desktop']={opus:'',sonnet:'gpt-one',haiku:'',desktopModels:[{model:'gpt-one',alias:'claude-opus-5',context1m:false}]}; let harnessProvider:Record={}; const harnessConfigurations:Record>={}; let harnessRevision=1; @@ -45,9 +48,11 @@ mockIPC(async (cmd,args:any) => { nativeOauth=true; return {outcome:'updated'}; } if(cmd==='get_agent_models') { + if(params.has('no-models'))return []; if(params.has('no-core'))throw new Error('CPA core is offline'); if(params.has('defer-models'))await new Promise(resolve=>{(window as any).fixtureFinishModels=resolve;}); if(args.client==='deepseek-harness')return harnessModels.map(m=>({name:m.id,inputModalities:m.defaults.input})); + if(params.has('claude-models')||params.has('claude-alias-models')||params.has('saved-desktop-alias'))return [{name:'gpt-one'},{name:'claude-opus-5',isAlias:!params.has('claude-models')},{name:'claude-sonnet-5',isAlias:!params.has('claude-models')}]; return [{name:'gpt-one'},{name:'gpt-two'}]; } if(cmd==='list_codex_sessions') {