From a7e9cc356dbd162803ae710f2d21fd5f21158b9a Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 2 Aug 2026 17:49:43 +0800 Subject: [PATCH] feat: improve Claude agent mapping and context handling --- src-tauri/src/main.rs | 673 ++++++++++++++++++++++++++++++++++++--- src/i18n/ja.ts | 7 - src/i18n/resources.ts | 14 - src/pages/AgentsPage.tsx | 64 ++-- src/styles.css | 170 +--------- 5 files changed, 672 insertions(+), 256 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index be58f19..1dd734c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -88,7 +88,14 @@ 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 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"; +const MANAGED_CLAUDE_HAIKU_ALIAS_DISPLAY_NAME: &str = + "EasyCLIProxyAPI managed Claude Haiku mapping"; +const DEFAULT_CLAUDE_CONTEXT_WINDOW: u64 = 200_000; const CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW: u64 = 1_000_000; +const CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV: &str = "CLAUDE_CODE_MAX_CONTEXT_TOKENS"; const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[ "codex-api-key", "openai-compatibility", @@ -2143,8 +2150,13 @@ async fn fetch_prepared_agent_models( let runtime_models = fetch_codex_runtime_models(config.port, api_key).await?; prepare_codex_agent_models(&runtime_models) } else { + let mut models = fetch_agent_models(config.port, api_key).await?; + if matches!(client, AgentClient::ClaudeCode | AgentClient::ClaudeDesktop) { + let content = fetch_management_config_yaml(config).await?; + mark_configured_agent_model_aliases(&mut models, &content)?; + } Ok(PreparedAgentModels { - models: fetch_agent_models(config.port, api_key).await?, + models, codex_catalog: None, }) } @@ -2581,10 +2593,19 @@ async fn launch_agent( .map(ClaudeDesktopModelMappings::all) }) .ok_or_else(|| "Claude Code model environment is not configured".to_string())?; + let prepared = fetch_prepared_agent_models(client, &config).await?; + let max_context_tokens = claude_code_max_context_tokens(&mappings, &prepared.models)?; + { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "Claude Code configuration lock is poisoned".to_string())?; + update_claude_code_context_window(&claude_config_path, max_context_tokens)?; + } let environment = claude_code_launch_environment( &format!("http://127.0.0.1:{port}"), effective_agent_api_key(&config), &mappings, + max_context_tokens, ); return launch_cli_agent( &executable, @@ -2719,6 +2740,10 @@ fn parse_agent_model_options(payload: &serde_json::Value) -> Result Result<(), String> { + let document = serde_norway::from_str::(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let root = document + .as_mapping() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + + for section in MODEL_ALIAS_CONFIG_SECTIONS { + let Some(providers) = yaml_mapping_value(root, section) else { + continue; + }; + let providers = providers + .as_sequence() + .ok_or_else(|| format!("{section} 必须是数组"))?; + for provider in providers { + let Some(provider) = provider.as_mapping() else { + continue; + }; + let Some(configured_models) = yaml_mapping_value(provider, "models") else { + continue; + }; + let configured_models = configured_models + .as_sequence() + .ok_or_else(|| format!("{section}.models 必须是数组"))?; + mark_agent_model_aliases_from_sequence(models, configured_models); + } + } + + if let Some(oauth_aliases) = yaml_mapping_value(root, "oauth-model-alias") { + let oauth_aliases = oauth_aliases + .as_mapping() + .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; + for entries in oauth_aliases.values() { + let Some(entries) = entries.as_sequence() else { + continue; + }; + mark_agent_model_aliases_from_sequence(models, entries); + } + } + Ok(()) +} + +fn mark_agent_model_aliases_from_sequence( + models: &mut [AgentModelOption], + configured_models: &[serde_norway::Value], +) { + for configured in configured_models { + let Some((source_model, client_model, _)) = configured_model_identity(configured) else { + continue; + }; + if source_model.eq_ignore_ascii_case(&client_model) { + continue; + } + let Some(model) = models + .iter_mut() + .find(|model| model.name.eq_ignore_ascii_case(&client_model)) + else { + continue; + }; + model.is_alias = true; + model.alias = Some(source_model); + } +} + fn parse_codex_model_definitions( payload: &serde_json::Value, ) -> Result, String> { @@ -4378,12 +4470,58 @@ fn inspect_claude_code_model_mappings( })) } +fn agent_model_context_window(models: &[AgentModelOption], model_name: &str) -> Option { + let selected = models + .iter() + .find(|model| model.name.eq_ignore_ascii_case(model_name))?; + if !selected.is_alias { + return Some( + selected + .context_window + .unwrap_or(DEFAULT_CLAUDE_CONTEXT_WINDOW), + ); + } + selected + .alias + .as_deref() + .and_then(|source| { + models + .iter() + .find(|model| model.name.eq_ignore_ascii_case(source)) + }) + .and_then(|model| model.context_window) + .or(selected.context_window) + .or(Some(DEFAULT_CLAUDE_CONTEXT_WINDOW)) +} + +fn claude_code_max_context_tokens( + mappings: &ClaudeDesktopModelMappings, + models: &[AgentModelOption], +) -> Result { + [ + ("Opus", mappings.opus.as_str()), + ("Sonnet", mappings.sonnet.as_str()), + ("Haiku", mappings.haiku.as_str()), + ] + .into_iter() + .map(|(role, model)| { + agent_model_context_window(models, model).ok_or_else(|| { + format!("CPA 模型 API 未返回 Claude Code {role} 映射模型 {model} 的上下文窗口") + }) + }) + .collect::, _>>()? + .into_iter() + .min() + .ok_or_else(|| "CPA 模型 API 未返回 Claude Code 上下文窗口".to_string()) +} + fn claude_code_launch_environment( base_url: &str, api_key: &str, mappings: &ClaudeDesktopModelMappings, + max_context_tokens: u64, ) -> Vec<(String, String)> { - [ + let mut environment = [ ("ANTHROPIC_BASE_URL", base_url), ("ANTHROPIC_AUTH_TOKEN", api_key), ("ANTHROPIC_MODEL", mappings.sonnet.as_str()), @@ -4394,7 +4532,12 @@ fn claude_code_launch_environment( ] .into_iter() .map(|(key, value)| (key.to_string(), value.to_string())) - .collect() + .collect::>(); + environment.push(( + CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), + max_context_tokens.to_string(), + )); + environment } fn inspect_claude_desktop_agent_config( @@ -4705,6 +4848,7 @@ fn build_agent_updates_with_oauth( &root_base, api_key, model, + models, claude_code_model_mappings, ) .or_else(|_| { @@ -4713,6 +4857,7 @@ fn build_agent_updates_with_oauth( &root_base, api_key, model, + models, claude_code_model_mappings, ) })?; @@ -4857,6 +5002,7 @@ fn build_claude_agent_config( base_url: &str, api_key: &str, model: &str, + models: &[AgentModelOption], mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { @@ -4870,6 +5016,7 @@ fn build_claude_agent_config( let mappings = mappings .cloned() .unwrap_or_else(|| ClaudeDesktopModelMappings::all(model)); + let max_context_tokens = claude_code_max_context_tokens(&mappings, models)?; let env = ensure_json_object_entry(root, "env"); env.remove("ANTHROPIC_API_KEY"); for (key, value) in [ @@ -4886,6 +5033,10 @@ fn build_claude_agent_config( serde_json::Value::String(value.to_string()), ); } + env.insert( + CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), + serde_json::Value::String(max_context_tokens.to_string()), + ); root.insert( "model".to_string(), serde_json::Value::String(mappings.sonnet), @@ -4913,6 +5064,34 @@ fn remove_claude_code_conflicting_api_key(path: &Path) -> Result { }) } +fn update_claude_code_context_window(path: &Path, max_context_tokens: u64) -> Result { + update_agent_json_file(path, "Claude Code configuration", |root| { + let managed = root + .get("env") + .and_then(serde_json::Value::as_object) + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(serde_json::Value::as_str) + .is_some_and(is_managed_agent_base_url); + if !managed { + return false; + } + let expected = max_context_tokens.to_string(); + let env = ensure_json_object_entry(root, "env"); + if env + .get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV) + .and_then(serde_json::Value::as_str) + == Some(expected.as_str()) + { + return false; + } + env.insert( + CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), + serde_json::Value::String(expected), + ); + true + }) +} + fn parse_agent_json_object( existing: Option<&str>, label: &str, @@ -5062,21 +5241,7 @@ fn claude_desktop_inference_model( .iter() .find(|model| model.name.eq_ignore_ascii_case(source_model)); let direct_alias = selected.is_some_and(|model| model.is_alias); - let context_model = selected.and_then(|model| { - if !model.is_alias { - return Some(model); - } - model - .alias - .as_deref() - .and_then(|source| { - models - .iter() - .find(|candidate| candidate.name.eq_ignore_ascii_case(source)) - }) - .or(Some(model)) - }); - let context_window = context_model.and_then(|model| model.context_window); + let context_window = agent_model_context_window(models, source_model); let mut entry = serde_json::Map::new(); entry.insert( "name".to_string(), @@ -5258,6 +5423,7 @@ fn remove_claude_code_managed_configuration(paths: &[PathBuf]) -> Result Ok(vec![ @@ -7925,15 +8093,17 @@ fn restore_agent_applied_state_configuration( Ok(()) } -fn restore_all_agent_session_configurations(home: &Path) { - for client in [ - AgentClient::ClaudeCode, - AgentClient::ClaudeDesktop, +fn agent_clients_restored_on_exit() -> [AgentClient; 4] { + [ AgentClient::Codex, AgentClient::OpenCode, AgentClient::OpenClaw, AgentClient::Hermes, - ] { + ] +} + +fn restore_all_agent_session_configurations(home: &Path) { + for client in agent_clients_restored_on_exit() { if let Err(error) = restore_agent_session_configuration(client, home) { eprintln!("关闭时恢复 {} 配置失败: {error}", client.name()); } @@ -8105,7 +8275,7 @@ fn reset_agent_configuration_to_default( name: model.to_string(), alias: None, is_alias: false, - context_window: None, + context_window: Some(200_000), }], codex_catalog, oauth_configuration: false, @@ -12123,6 +12293,15 @@ async fn ensure_claude_desktop_model_aliases( Ok(()) } +async fn remove_managed_claude_model_aliases(config: &GuiConfigFile) -> Result<(), String> { + let content = fetch_management_config_yaml(config).await?; + let updated = remove_managed_claude_model_aliases_in_yaml(&content)?; + if updated != content { + put_management_config_yaml(config, &updated).await?; + } + Ok(()) +} + fn ensure_claude_desktop_model_aliases_in_yaml( content: &str, mappings: &ClaudeDesktopModelMappings, @@ -12145,7 +12324,7 @@ fn ensure_claude_desktop_model_aliases_in_yaml( .any(|model| model.name.eq_ignore_ascii_case(source_model) && model.is_alias); if direct_alias { if !source_model.eq_ignore_ascii_case(alias) { - remove_claude_desktop_model_alias(root, alias)?; + remove_managed_claude_model_alias(root, alias)?; } } else { ensure_claude_desktop_model_alias(root, source_model, alias)?; @@ -12154,10 +12333,32 @@ fn ensure_claude_desktop_model_aliases_in_yaml( render_updated_core_yaml(&mut document, updated) } -fn remove_claude_desktop_model_alias( +fn remove_managed_claude_model_aliases_in_yaml(content: &str) -> Result { + let mut document = yaml_serde_edit::YamlValue::parse(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let mut updated = document.get().clone(); + let root = updated + .as_mapping_mut() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let mut changed = false; + for alias in [ + CLAUDE_DESKTOP_OPUS_MODEL_ID, + CLAUDE_DESKTOP_SONNET_MODEL_ID, + CLAUDE_DESKTOP_HAIKU_MODEL_ID, + ] { + changed |= remove_managed_claude_model_alias(root, alias)?; + } + if !changed { + return Ok(content.to_string()); + } + render_updated_core_yaml(&mut document, updated) +} + +fn remove_managed_claude_model_alias( root: &mut serde_norway::Mapping, alias: &str, -) -> Result<(), String> { +) -> Result { + let mut changed = false; for section in MODEL_ALIAS_CONFIG_SECTIONS { let Some(providers) = yaml_mapping_value_mut(root, section) else { continue; @@ -12175,13 +12376,84 @@ fn remove_claude_desktop_model_alias( let models = models .as_sequence_mut() .ok_or_else(|| format!("{section}.models 必须是数组"))?; + let before = models.len(); + models.retain(|model| !is_managed_claude_model_alias(model, alias)); + changed |= models.len() != before; + } + } + Ok(changed) +} + +fn remove_existing_claude_model_alias( + root: &mut serde_norway::Mapping, + alias: &str, +) -> Result { + let mut changed = false; + for section in MODEL_ALIAS_CONFIG_SECTIONS { + let Some(providers) = yaml_mapping_value_mut(root, section) else { + continue; + }; + let providers = providers + .as_sequence_mut() + .ok_or_else(|| format!("{section} 必须是数组"))?; + for provider in providers { + let Some(provider) = provider.as_mapping_mut() else { + continue; + }; + let Some(models) = yaml_mapping_value_mut(provider, "models") else { + continue; + }; + let models = models + .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)) }); + changed |= models.len() != before; } } - Ok(()) + Ok(changed) +} + +fn managed_claude_alias_display_name(alias: &str) -> Option<&'static str> { + if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_OPUS_MODEL_ID) { + Some(MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME) + } else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_SONNET_MODEL_ID) { + Some(MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME) + } else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_HAIKU_MODEL_ID) { + Some(MANAGED_CLAUDE_HAIKU_ALIAS_DISPLAY_NAME) + } else { + None + } +} + +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; + }; + let Some((_, client_model, display_name)) = configured_model_identity(model) else { + return false; + }; + client_model.eq_ignore_ascii_case(alias) + && display_name + .as_deref() + .is_some_and(|value| value == expected_display_name) +} + +fn configured_managed_claude_alias_exists(root: &serde_norway::Mapping, alias: &str) -> bool { + MODEL_ALIAS_CONFIG_SECTIONS.iter().any(|section| { + yaml_mapping_value(root, section) + .and_then(serde_norway::Value::as_sequence) + .into_iter() + .flatten() + .filter_map(serde_norway::Value::as_mapping) + .filter_map(|provider| yaml_mapping_value(provider, "models")) + .filter_map(serde_norway::Value::as_sequence) + .flatten() + .any(|model| is_managed_claude_model_alias(model, alias)) + }) } fn ensure_claude_desktop_model_alias( @@ -12190,12 +12462,12 @@ fn ensure_claude_desktop_model_alias( alias: &str, ) -> Result<(), String> { if let Some((existing_source, _)) = configured_model_client_identity(root, alias) { - if existing_source.eq_ignore_ascii_case(source_model) { + if existing_source.eq_ignore_ascii_case(source_model) + && configured_managed_claude_alias_exists(root, alias) + { return Ok(()); } - return Err(format!( - "Claude Desktop 别名 {alias} 已被模型 {existing_source} 使用,请先修改冲突模型的别名" - )); + remove_existing_claude_model_alias(root, alias)?; } if append_claude_desktop_model_alias(root, source_model, alias)? { return Ok(()); @@ -12285,6 +12557,12 @@ fn append_claude_desktop_model_alias( yaml_key("alias"), serde_norway::Value::String(alias.to_string()), ); + let display_name = managed_claude_alias_display_name(alias) + .ok_or_else(|| format!("不支持的 Claude 托管别名: {alias}"))?; + alias_model.insert( + yaml_key("display-name"), + serde_norway::Value::String(display_name.to_string()), + ); models.push(serde_norway::Value::Mapping(alias_model)); return Ok(true); } @@ -13496,12 +13774,123 @@ fn render_updated_core_yaml( updated: serde_norway::Value, ) -> Result { document.set(updated); - let rendered = document.get_string(); + let rendered = expand_top_level_flow_style_collections(&document.get_string(), document.get())?; + let rendered = indent_indentationless_yaml_sequences(&rendered); serde_norway::from_str::(&rendered) .map_err(|error| format!("验证更新后的内核配置失败: {error}"))?; Ok(rendered) } +fn expand_top_level_flow_style_collections( + content: &str, + document: &serde_norway::Value, +) -> Result { + let root = document + .as_mapping() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let mut rendered = content.to_string(); + for (key, value) in root { + let Some(key) = key.as_str() else { + continue; + }; + let is_non_empty_collection = match value { + serde_norway::Value::Mapping(mapping) => !mapping.is_empty(), + serde_norway::Value::Sequence(sequence) => !sequence.is_empty(), + _ => false, + }; + if !is_non_empty_collection || !top_level_yaml_entry_uses_flow_style(&rendered, key) { + continue; + } + let mut wrapper = serde_norway::Mapping::new(); + wrapper.insert(yaml_key(key), value.clone()); + let block = serde_norway::to_string(&serde_norway::Value::Mapping(wrapper)) + .map_err(|error| format!("格式化内核 YAML 配置失败: {error}"))?; + rendered = replace_top_level_yaml_block(&rendered, key, &block); + } + Ok(rendered) +} + +fn top_level_yaml_entry_uses_flow_style(content: &str, key: &str) -> bool { + let prefix = format!("{key}:"); + yaml_line_ranges(content).into_iter().any(|range| { + let line = yaml_line_content(content, range); + if line.chars().next().is_some_and(char::is_whitespace) { + return false; + } + line.strip_prefix(&prefix) + .map(str::trim_start) + .is_some_and(|value| value.starts_with('[') || value.starts_with('{')) + }) +} + +fn indent_indentationless_yaml_sequences(content: &str) -> String { + let mut lines = content + .split_inclusive('\n') + .map(str::to_string) + .collect::>(); + if !content.is_empty() && !content.ends_with('\n') && lines.is_empty() { + lines.push(content.to_string()); + } + + loop { + let mut range_to_indent = None; + for key_index in 0..lines.len() { + let key_line = lines[key_index].trim_end_matches(['\r', '\n']); + let key_trimmed = key_line.trim_start_matches(' '); + if key_trimmed.is_empty() + || key_trimmed.starts_with('#') + || key_trimmed.starts_with('-') + || !key_trimmed.ends_with(':') + { + continue; + } + let parent_indent = key_line.len() - key_trimmed.len(); + let Some(first_item) = ((key_index + 1)..lines.len()).find(|index| { + let line = lines[*index].trim_end_matches(['\r', '\n']).trim(); + !line.is_empty() && !line.starts_with('#') + }) else { + continue; + }; + let first_line = lines[first_item].trim_end_matches(['\r', '\n']); + let first_trimmed = first_line.trim_start_matches(' '); + let first_indent = first_line.len() - first_trimmed.len(); + if first_indent != parent_indent + || !is_indentationless_yaml_sequence_item(first_trimmed) + { + continue; + } + + let mut end = first_item; + while end < lines.len() { + let line = lines[end].trim_end_matches(['\r', '\n']); + let trimmed = line.trim_start_matches(' '); + if !trimmed.is_empty() { + let indent = line.len() - trimmed.len(); + if indent < parent_indent + || (indent == parent_indent + && !is_indentationless_yaml_sequence_item(trimmed)) + { + break; + } + } + end += 1; + } + range_to_indent = Some((first_item, end)); + break; + } + + let Some((start, end)) = range_to_indent else { + break; + }; + for line in &mut lines[start..end] { + if !line.trim().is_empty() { + line.insert_str(0, " "); + } + } + } + lines.concat() +} + fn management_http_client() -> Result { reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) @@ -17284,12 +17673,24 @@ fn main() { } tauri::RunEvent::Exit => { usage::stop_usage_collector(app_handle); + let gui_config_state = app_handle.state::(); + match gui_config_state.snapshot() { + Ok(config) => { + if let Err(error) = + tauri::async_runtime::block_on(remove_managed_claude_model_aliases(&config)) + { + eprintln!("退出时清理 EasyCLIProxyAPI 托管的 Claude 模型别名失败: {error}"); + } + } + Err(error) => { + eprintln!("退出时读取 GUI 配置失败,无法清理 Claude 模型别名: {error}"); + } + } if let Ok(home) = app_handle.path().home_dir() { let _guard = AGENT_CONFIG_FILE_LOCK.lock(); restore_all_agent_session_configurations(&home); } let process_state = app_handle.state::(); - let gui_config_state = app_handle.state::(); shutdown_managed_core(process_state.inner(), gui_config_state.inner()); } _ => {} @@ -17447,7 +17848,7 @@ data: {"delta":{"type":"text_delta","text":"H"}} name: (*name).to_string(), alias: None, is_alias: false, - context_window: None, + context_window: Some(200_000), }) .collect() } @@ -17590,6 +17991,7 @@ data: {"delta":{"type":"text_delta","text":"H"}} "http://127.0.0.1:8317", DEFAULT_API_KEY, "gpt-test", + &test_agent_models(&["gpt-test"]), None, ) .unwrap(); @@ -18496,6 +18898,7 @@ model: "http://127.0.0.1:8317", DEFAULT_API_KEY, "model-a", + &test_agent_models(&["model-a"]), None, ) .unwrap(); @@ -18741,6 +19144,7 @@ model: "http://127.0.0.1:8317", DEFAULT_API_KEY, "claude-test", + &test_agent_models(&["claude-test"]), None, ) .unwrap(); @@ -18762,11 +19166,38 @@ model: sonnet: "gpt-sonnet".to_string(), haiku: "gpt-haiku".to_string(), }; + let models = vec![ + AgentModelOption { + name: "gpt-opus-base".to_string(), + alias: None, + is_alias: false, + context_window: Some(1_000_000), + }, + AgentModelOption { + name: mappings.opus.clone(), + alias: Some("gpt-opus-base".to_string()), + is_alias: true, + context_window: Some(128_000), + }, + AgentModelOption { + name: mappings.sonnet.clone(), + alias: None, + is_alias: false, + context_window: Some(272_000), + }, + AgentModelOption { + name: mappings.haiku.clone(), + alias: None, + is_alias: false, + context_window: Some(512_000), + }, + ]; let rendered = build_claude_agent_config( None, "http://127.0.0.1:8317", "test-key", "gpt-sonnet", + &models, Some(&mappings), ) .unwrap(); @@ -18775,9 +19206,14 @@ model: assert_eq!(value["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], "gpt-opus"); assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], "gpt-sonnet"); assert_eq!(value["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"], "gpt-haiku"); + assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "272000"); - let environment = - claude_code_launch_environment("http://127.0.0.1:8317", "test-key", &mappings); + let environment = claude_code_launch_environment( + "http://127.0.0.1:8317", + "test-key", + &mappings, + claude_code_max_context_tokens(&mappings, &models).unwrap(), + ); let read = |key: &str| { environment .iter() @@ -18790,6 +19226,23 @@ model: assert_eq!(read("ANTHROPIC_DEFAULT_OPUS_MODEL"), Some("gpt-opus")); assert_eq!(read("ANTHROPIC_DEFAULT_SONNET_MODEL"), Some("gpt-sonnet")); assert_eq!(read("ANTHROPIC_DEFAULT_HAIKU_MODEL"), Some("gpt-haiku")); + assert_eq!(read(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV), Some("272000")); + } + + #[test] + fn claude_code_uses_200k_when_cpa_context_metadata_is_missing() { + let mappings = ClaudeDesktopModelMappings::all("custom-model"); + let models = vec![AgentModelOption { + name: "custom-model".to_string(), + alias: None, + is_alias: false, + context_window: None, + }]; + + assert_eq!( + claude_code_max_context_tokens(&mappings, &models).unwrap(), + DEFAULT_CLAUDE_CONTEXT_WINDOW + ); } #[test] @@ -18803,10 +19256,13 @@ model: .unwrap(); assert!(remove_claude_code_conflicting_api_key(&path).unwrap()); + assert!(update_claude_code_context_window(&path, 272_000).unwrap()); + assert!(!update_claude_code_context_window(&path, 272_000).unwrap()); let value: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); assert!(value["env"].get("ANTHROPIC_API_KEY").is_none()); assert_eq!(value["env"]["ANTHROPIC_AUTH_TOKEN"], "token"); + assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "272000"); fs::remove_dir_all(directory).unwrap(); } @@ -19274,7 +19730,7 @@ model: "data": [ {"id": "gpt-5", "display_name": "GPT 5", "context_length": 272000}, {"name": "claude-sonnet", "alias": "claude-sonnet-xhigh", "fork": true, "contextLength": "1000000"}, - {"name": "hidden-original", "alias": "visible-alias", "context_window": 128000}, + {"name": "hidden-original", "alias": "visible-alias", "ContextLength": 128000}, "deepseek-chat", {"id": "GPT-5"}, {"id": ""} @@ -19319,6 +19775,52 @@ model: ); } + #[test] + fn claude_model_lists_mark_yaml_aliases_when_api_returns_only_model_ids() { + let mut models = test_agent_models(&[ + "gpt-original", + "gpt-high", + "claude-opus-5", + "oauth-original", + "oauth-fast", + ]); + let yaml = r#" +codex-api-key: + - api-key: test + models: + - name: gpt-original + - name: gpt-original + alias: gpt-high + - name: gpt-original + alias: claude-opus-5 +oauth-model-alias: + codex: + - name: oauth-original + alias: oauth-fast + fork: true +"#; + + mark_configured_agent_model_aliases(&mut models, yaml).unwrap(); + + let find = |name: &str| models.iter().find(|model| model.name == name).unwrap(); + assert!(!find("gpt-original").is_alias); + assert_eq!(find("gpt-high").alias.as_deref(), Some("gpt-original")); + assert!(find("gpt-high").is_alias); + assert_eq!(find("claude-opus-5").alias.as_deref(), Some("gpt-original")); + assert!(find("claude-opus-5").is_alias); + assert_eq!(find("oauth-fast").alias.as_deref(), Some("oauth-original")); + assert!(find("oauth-fast").is_alias); + } + + #[test] + fn exit_preserves_claude_client_configurations() { + let restored = agent_clients_restored_on_exit(); + assert!(!restored.contains(&AgentClient::ClaudeCode)); + assert!(!restored.contains(&AgentClient::ClaudeDesktop)); + assert!(restored.contains(&AgentClient::Codex)); + assert!(restored.contains(&AgentClient::OpenCode)); + } + #[test] fn claude_desktop_aliases_expose_role_routes_only() { let input = "openai-compatibility:\n - name: CPA\n base-url: https://example.com/v1\n models:\n - name: gpt-5.6-sol\n"; @@ -19353,6 +19855,23 @@ model: configured_model_identity(&models[3]).unwrap().1, CLAUDE_DESKTOP_HAIKU_MODEL_ID ); + assert_eq!( + configured_model_identity(&models[1]).unwrap().2.as_deref(), + Some(MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME) + ); + assert_eq!( + configured_model_identity(&models[2]).unwrap().2.as_deref(), + Some(MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME) + ); + assert_eq!( + configured_model_identity(&models[3]).unwrap().2.as_deref(), + Some(MANAGED_CLAUDE_HAIKU_ALIAS_DISPLAY_NAME) + ); + 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("\n display-name: EasyCLIProxyAPI managed Claude Opus mapping\n")); assert_eq!( ensure_claude_desktop_model_aliases_in_yaml(&rendered, &mappings, &available_models,) .unwrap(), @@ -19360,9 +19879,83 @@ model: ); } + #[test] + fn claude_managed_aliases_expand_flow_yaml_and_are_removed_selectively() { + let compact = "# keep this comment\ncodex-api-key: [{api-key: test, models: [{name: gpt-5.6-sol, alias: ''}]}]\ncredential-concurrency: {max-limit: 1000000, cleanup-interval: 5s}\n"; + let mappings = ClaudeDesktopModelMappings::all("gpt-5.6-sol"); + let available_models = test_agent_models(&["gpt-5.6-sol"]); + let rendered = + ensure_claude_desktop_model_aliases_in_yaml(compact, &mappings, &available_models) + .unwrap(); + + assert!(rendered.starts_with("# keep this comment\n")); + assert!(!rendered.contains("codex-api-key: [")); + assert!(!rendered.contains("credential-concurrency: {")); + assert!(rendered.contains("codex-api-key:\n - api-key: test\n")); + assert!(rendered.contains(" models:\n - name: gpt-5.6-sol\n")); + + let cleaned = remove_managed_claude_model_aliases_in_yaml(&rendered).unwrap(); + let value: serde_norway::Value = serde_norway::from_str(&cleaned).unwrap(); + let root = value.as_mapping().unwrap(); + let providers = yaml_mapping_value(root, "codex-api-key") + .and_then(serde_norway::Value::as_sequence) + .unwrap(); + let models = yaml_mapping_value(providers[0].as_mapping().unwrap(), "models") + .and_then(serde_norway::Value::as_sequence) + .unwrap(); + assert_eq!(models.len(), 1); + assert_eq!( + configured_model_identity(&models[0]).unwrap().0, + "gpt-5.6-sol" + ); + } + + #[test] + fn claude_alias_cleanup_does_not_delete_user_owned_routes() { + let input = format!( + "codex-api-key:\n - api-key: test\n models:\n - name: user-opus\n alias: {opus}\n display-name: User managed route\n - name: app-sonnet\n alias: {sonnet}\n display-name: {managed_sonnet}\n", + opus = CLAUDE_DESKTOP_OPUS_MODEL_ID, + sonnet = CLAUDE_DESKTOP_SONNET_MODEL_ID, + managed_sonnet = MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME, + ); + + let cleaned = remove_managed_claude_model_aliases_in_yaml(&input).unwrap(); + let value: serde_norway::Value = serde_norway::from_str(&cleaned).unwrap(); + let root = value.as_mapping().unwrap(); + assert_eq!( + configured_model_client_identity(root, CLAUDE_DESKTOP_OPUS_MODEL_ID) + .unwrap() + .0, + "user-opus" + ); + assert!(configured_model_client_identity(root, CLAUDE_DESKTOP_SONNET_MODEL_ID).is_none()); + } + + #[test] + fn claude_legacy_aliases_are_adopted_and_moved_to_the_selected_model() { + let input = format!( + "codex-api-key:\n - api-key: test\n models:\n - name: old-model\n - name: new-model\n - name: old-model\n alias: {opus}\n", + opus = CLAUDE_DESKTOP_OPUS_MODEL_ID, + ); + let mappings = ClaudeDesktopModelMappings::all("new-model"); + let models = test_agent_models(&["old-model", "new-model"]); + + let rendered = + ensure_claude_desktop_model_aliases_in_yaml(&input, &mappings, &models).unwrap(); + let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); + let root = value.as_mapping().unwrap(); + assert_eq!( + configured_model_client_identity(root, CLAUDE_DESKTOP_OPUS_MODEL_ID) + .unwrap() + .0, + "new-model" + ); + assert!(rendered.contains(MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME)); + } + #[test] fn claude_desktop_uses_selected_alias_directly_with_original_context() { - let input = "openai-compatibility:\n - name: CPA\n base-url: https://example.com/v1\n models:\n - name: gpt-original\n - name: gpt-original\n alias: gpt-high\n - name: gpt-original\n alias: claude-opus-5\n"; + let input = "openai-compatibility:\n - name: CPA\n base-url: https://example.com/v1\n models:\n - name: gpt-original\n - name: gpt-original\n alias: gpt-high\n - name: gpt-original\n alias: claude-opus-5\n display-name: EasyCLIProxyAPI managed Claude Opus mapping\n"; let mappings = ClaudeDesktopModelMappings { opus: "gpt-high".to_string(), sonnet: "gpt-high".to_string(), diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index f471438..9d93117 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -554,13 +554,6 @@ export const jaOverrides = { 'agents.claudeDesktopMapping.description': '各 Claude Desktop モデルが実際に呼び出す上流モデルを選択します。', 'agents.claudeCodeMapping.title': 'Claude Code モデル → 上流モデル', 'agents.claudeCodeMapping.description': '各タスク役割に対して、起動時に設定する上流モデルを選択します。', - 'agents.claudeCodeMapping.aliasOnly': 'エイリアスモデルのみ表示', - 'agents.claudeCodeMapping.opus': 'Opus', - 'agents.claudeCodeMapping.opusHint': '複雑な推論と高難度タスク', - 'agents.claudeCodeMapping.sonnet': 'Sonnet', - 'agents.claudeCodeMapping.sonnetHint': '既定のメインモデル', - 'agents.claudeCodeMapping.haiku': 'Haiku', - 'agents.claudeCodeMapping.haikuHint': '高速で軽量なタスク', 'agents.claudeDesktopMapping.customMapping': 'カスタムマッピング', 'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。', 'agents.claudeDesktopMapping.opus': 'Claude Opus 5 のマッピング先', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 997cac3..c83bf53 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -552,13 +552,6 @@ export const zhCN = { 'agents.claudeDesktopMapping.description': '选择每个 Claude Desktop 模型实际调用的上游模型。', 'agents.claudeCodeMapping.title': 'Claude Code 模型 → 上游模型', 'agents.claudeCodeMapping.description': '为不同任务角色选择启动时注入的上游模型。', - 'agents.claudeCodeMapping.aliasOnly': '仅显示别名模型', - 'agents.claudeCodeMapping.opus': 'Opus', - 'agents.claudeCodeMapping.opusHint': '复杂推理与高难度任务', - 'agents.claudeCodeMapping.sonnet': 'Sonnet', - 'agents.claudeCodeMapping.sonnetHint': '默认主模型', - 'agents.claudeCodeMapping.haiku': 'Haiku', - 'agents.claudeCodeMapping.haikuHint': '快速轻量任务', 'agents.claudeDesktopMapping.customMapping': '自定义映射', 'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。', 'agents.claudeDesktopMapping.opus': 'Claude Opus 5 映射到', @@ -1487,13 +1480,6 @@ export const en: Record = { 'agents.claudeDesktopMapping.description': 'Choose the upstream model each Claude Desktop model actually calls.', 'agents.claudeCodeMapping.title': 'Claude Code Model → Upstream Model', 'agents.claudeCodeMapping.description': 'Choose the upstream model injected at launch for each task role.', - 'agents.claudeCodeMapping.aliasOnly': 'Show aliases only', - 'agents.claudeCodeMapping.opus': 'Opus', - 'agents.claudeCodeMapping.opusHint': 'Complex reasoning and difficult tasks', - 'agents.claudeCodeMapping.sonnet': 'Sonnet', - 'agents.claudeCodeMapping.sonnetHint': 'Default primary model', - 'agents.claudeCodeMapping.haiku': 'Haiku', - 'agents.claudeCodeMapping.haikuHint': 'Fast lightweight tasks', 'agents.claudeDesktopMapping.customMapping': 'Custom Mapping', 'agents.claudeDesktopMapping.customMappingHint': 'When enabled, only aliased models are shown; when disabled, only original models without aliases are shown.', 'agents.claudeDesktopMapping.opus': 'Map Claude Opus 5 to', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 0e0a64d..db4a84d 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -123,20 +123,14 @@ const claudeMappingRoles = [ { key: 'opus', labelKey: 'agents.claudeDesktopMapping.opus', - codeLabelKey: 'agents.claudeCodeMapping.opus', - codeHintKey: 'agents.claudeCodeMapping.opusHint', }, { key: 'sonnet', labelKey: 'agents.claudeDesktopMapping.sonnet', - codeLabelKey: 'agents.claudeCodeMapping.sonnet', - codeHintKey: 'agents.claudeCodeMapping.sonnetHint', }, { key: 'haiku', labelKey: 'agents.claudeDesktopMapping.haiku', - codeLabelKey: 'agents.claudeCodeMapping.haiku', - codeHintKey: 'agents.claudeCodeMapping.haikuHint', }, ] as const; @@ -1177,33 +1171,30 @@ export function AgentsPage() {
- {selected !== 'claude-code' ? ( -
- {availableSubpages.map((subpage) => ( - - ))} -
- ) : null} +
+ {availableSubpages.map((subpage) => ( + + ))} +
{activeSubpage === 'core' ? (
@@ -1254,7 +1245,7 @@ export function AgentsPage() { ) : null} {isClaudeModelMappingClient ? ( -
+
{t(selected === 'claude-code' @@ -1269,9 +1260,7 @@ export function AgentsPage() { className="agent-claude-desktop-mapping-filter" title={t('agents.claudeDesktopMapping.customMappingHint')} > - {t(selected === 'claude-code' - ? 'agents.claudeCodeMapping.aliasOnly' - : 'agents.claudeDesktopMapping.customMapping')} + {t('agents.claudeDesktopMapping.customMapping')}
{claudeMappingRoles.map((role) => ( -
-
- {t(selected === 'claude-code' ? role.codeLabelKey : role.labelKey)} - {selected === 'claude-code' ? {t(role.codeHintKey)} : null} -
+
+ {t(role.labelKey)} {busyAction === 'default' ? - : selected === 'claude-code' ? null : } + : } {t('agents.modify.default')} {selected === 'codex' ? ( @@ -1436,7 +1422,7 @@ export function AgentsPage() { > {busyAction === 'launch' ? - : selected === 'claude-code' ? null : } + : } {busyAction === 'launch' ? t('agents.launch.starting') : defaultLaunchTarget ? t('agents.launch.start', { target: defaultLaunchTarget.label }) : t('agents.launch.unavailable')} )} diff --git a/src/styles.css b/src/styles.css index bbc6eb8..7485f61 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3456,7 +3456,7 @@ textarea:focus-visible { .real-provider-row { display: grid; - grid-template-columns: minmax(280px, 1.6fr) minmax(120px, 0.5fr) auto; + grid-template-columns: minmax(280px, 1.6fr) minmax(120px, 0.5fr) max-content; align-items: start; gap: 16px; min-height: 88px; @@ -3559,10 +3559,15 @@ textarea:focus-visible { } .provider-row-actions .provider-health-button { + min-width: max-content; width: auto; + flex: 0 0 auto; min-height: 30px; + overflow: visible; padding: 0 10px; font-size: var(--font-size-label); + text-overflow: clip; + white-space: nowrap; } .provider-enabled-control { @@ -5310,127 +5315,6 @@ textarea:focus-visible { font-size: var(--font-size-label); } -.agent-claude-mapping-role { - display: grid; - min-width: 0; - gap: 3px; -} - -.agent-claude-mapping-role > strong { - color: var(--theme-5f5a53); - font-size: var(--font-size-label); -} - -.agent-claude-mapping-role > span { - overflow: hidden; - color: var(--theme-8a847c); - font-size: var(--font-size-meta); - text-overflow: ellipsis; - white-space: nowrap; -} - -.agent-claude-code-config { - gap: 12px; -} - -.agent-claude-code-config .agent-status-grid { - min-height: 0; - gap: 0; - padding: 10px 6px; - border: 1px solid var(--theme-e3e1db); - border-radius: 8px; - background: var(--theme-fffdf8); -} - -.agent-claude-code-config .agent-status-grid > div { - gap: 3px; - padding: 2px 12px; - border: 0; - border-radius: 0; - background: transparent; -} - -.agent-claude-code-config .agent-status-grid > div + div { - border-left: 1px solid var(--theme-e3e1db); -} - -.agent-claude-code-mapping { - gap: 14px; - padding: 18px; - border-color: var(--theme-d8d2c8); - background: var(--theme-fffdf8); -} - -.agent-claude-code-mapping > .agent-section-heading { - padding-bottom: 13px; - border-bottom: 1px solid var(--theme-e3e1db); -} - -.agent-claude-code-mapping .agent-claude-desktop-mapping-grid { - gap: 12px; -} - -.agent-claude-code-mapping .agent-claude-desktop-mapping-row { - position: relative; - gap: 10px; - padding: 13px; - overflow: hidden; - border: 1px solid var(--theme-e3e1db); - border-radius: 8px; - background: var(--theme-fff); -} - -.agent-claude-code-mapping .agent-claude-desktop-mapping-row::before { - position: absolute; - top: 0; - right: 0; - left: 0; - height: 3px; - background: var(--theme-bdb5a9); - content: ''; -} - -.agent-claude-code-mapping .agent-claude-desktop-mapping-row.sonnet { - border-color: var(--theme-d8d1eb); - background: var(--theme-f8f5ff); -} - -.agent-claude-code-mapping .agent-claude-desktop-mapping-row.sonnet::before { - background: var(--theme-8069ad); -} - -.agent-claude-code-mapping .agent-claude-desktop-mapping-row.opus::before { - background: var(--theme-bf8a43); -} - -.agent-claude-code-mapping .agent-claude-desktop-mapping-row.haiku::before { - background: var(--theme-4f825c); -} - -.agent-claude-code-mapping .agent-claude-desktop-mapping-filter { - padding: 7px 9px; - border-radius: 8px; -} - -.agent-claude-code-config .agent-modification-actions { - grid-column: 1 / -1; - background: var(--theme-fffdf8); -} - -.agent-claude-code-config .agent-modification-control > .agent-inline-message { - width: 100%; -} - -.agent-claude-code-config .agent-config-footer { - padding-top: 0; - border-top: 0; -} - -.agent-claude-code-config .agent-launch-button { - min-width: 190px; - min-height: 42px; -} - .agent-section-heading-actions { display: flex !important; flex: none; @@ -7294,27 +7178,6 @@ textarea:focus-visible { height: 100%; } - .agent-claude-code-config .agent-modification-actions { - grid-column: 1 / -1; - grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); - align-items: center; - height: auto; - } - - .agent-claude-code-config .agent-modification-control { - justify-items: end; - } - - .agent-claude-code-config .agent-modification-buttons { - display: flex; - width: auto; - justify-content: flex-end; - } - - .agent-claude-code-config .agent-modification-buttons button { - width: auto; - min-width: 152px; - } } @container agent-panel (max-width: 760px) { @@ -7345,19 +7208,6 @@ textarea:focus-visible { } @container agent-panel (max-width: 640px) { - .agent-claude-code-config .agent-status-grid { - padding: 6px 12px; - } - - .agent-claude-code-config .agent-status-grid > div { - padding: 8px 0; - } - - .agent-claude-code-config .agent-status-grid > div + div { - border-top: 1px solid var(--theme-e3e1db); - border-left: 0; - } - .agent-claude-desktop-mapping > .agent-section-heading { align-items: stretch; flex-direction: column; @@ -7455,6 +7305,14 @@ textarea:focus-visible { height: 520px; } + .real-provider-row { + grid-template-columns: minmax(0, 1fr) max-content; + } + + .provider-row-meta { + display: none; + } + .agent-workbench { grid-template-columns: 200px minmax(0, 1fr); }