diff --git a/.gitignore b/.gitignore index 3fcc3d7..f6f8eb6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ src-tauri/target/ bin-work/ cpa-core/ -agent_docs +agent_docs/ *.log *.tmp diff --git a/src-tauri/resources/claude_models/model-catalog.json b/src-tauri/resources/claude_models/model-catalog.json new file mode 100644 index 0000000..06d9d70 --- /dev/null +++ b/src-tauri/resources/claude_models/model-catalog.json @@ -0,0 +1,69 @@ +{ + "fallback_model": { + "context_window": 200000 + }, + "models": [ + { "slug": "gpt-5.6" }, + { "slug": "gpt-5.6-sol", "context_window": 372000 }, + { "slug": "gpt-5.6-terra", "context_window": 372000 }, + { "slug": "gpt-5.6-luna", "context_window": 372000 }, + { "slug": "gpt-5.5", "context_window": 272000 }, + { "slug": "gpt-5.5-pro" }, + { "slug": "gpt-5.4", "context_window": 272000 }, + { "slug": "gpt-5.4-pro" }, + { "slug": "gpt-5.4-mini", "context_window": 272000 }, + { "slug": "gpt-5.4-nano" }, + { "slug": "gpt-5.3-codex" }, + { "slug": "gpt-5.3-codex-spark", "context_window": 128000 }, + { "slug": "gpt-5.2" }, + { "slug": "gpt-5.2-pro" }, + { "slug": "gpt-5.2-codex" }, + { "slug": "gpt-5.1" }, + { "slug": "gpt-5.1-codex" }, + { "slug": "gpt-5.1-codex-mini" }, + { "slug": "gpt-5" }, + { "slug": "gpt-5-pro" }, + { "slug": "gpt-5-mini" }, + { "slug": "gpt-5-nano" }, + { "slug": "gpt-4.1" }, + { "slug": "gpt-4.1-mini" }, + { "slug": "gpt-4.1-nano" }, + { "slug": "o1" }, + { "slug": "o1-pro" }, + { "slug": "o3" }, + { "slug": "o3-mini" }, + { "slug": "o3-pro" }, + { "slug": "o4-mini" }, + { "slug": "codex-auto-review", "context_window": 272000 }, + { "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-4-6" }, + { "slug": "claude-sonnet-4-5" }, + { "slug": "claude-sonnet-4" }, + { "slug": "claude-haiku-4-5" }, + { "slug": "gemini-3.6-flash" }, + { "slug": "gemini-3.5-flash" }, + { "slug": "gemini-3.1-pro-preview" }, + { "slug": "gemini-3.1-flash-lite" }, + { "slug": "gemini-3-pro-preview" }, + { "slug": "gemini-3-flash-preview" }, + { "slug": "gemini-2.5-pro" }, + { "slug": "gemini-2.5-flash" }, + { "slug": "gemini-2.5-flash-lite" }, + { "slug": "deepseek-v4-flash", "display_name": "DeepSeek V4 Flash", "context_window": 1000000, "claude_code_1m": true, "claude_code_effort_level": "max" }, + { "slug": "deepseek-v4-pro", "display_name": "DeepSeek V4 Pro", "context_window": 1000000, "claude_code_1m": true, "claude_code_effort_level": "max" }, + { "slug": "deepseek-chat", "display_name": "DeepSeek Chat" }, + { "slug": "deepseek-reasoner", "display_name": "DeepSeek Reasoner" }, + { "slug": "grok-4.5" }, + { "slug": "grok-4.3" }, + { "slug": "grok-4.20" }, + { "slug": "grok-4" }, + { "slug": "grok-4-1-fast" }, + { "slug": "grok-3" }, + { "slug": "grok-3-mini" } + ] +} diff --git a/src-tauri/src/claude_catalog.rs b/src-tauri/src/claude_catalog.rs new file mode 100644 index 0000000..9952e7b --- /dev/null +++ b/src-tauri/src/claude_catalog.rs @@ -0,0 +1,235 @@ +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +const MODEL_CATALOG_JSON: &str = include_str!("../resources/claude_models/model-catalog.json"); + +static CATALOG_STATE: OnceLock> = OnceLock::new(); + +#[derive(Clone, Debug)] +struct ClaudeCatalog { + fallback_context_window: u64, + context_windows: HashMap, + display_names: HashMap, + claude_code_1m_models: HashSet, + claude_code_effort_levels: HashMap, +} + +pub(crate) fn context_window_for( + model_name: &str, + runtime_context_window: Option, +) -> Result { + let catalog = catalog_state()?; + Ok(catalog + .context_windows + .get(&normalize_id(model_name)) + .copied() + .or(runtime_context_window) + .unwrap_or(catalog.fallback_context_window)) +} + +pub(crate) fn display_name_for(model_name: &str) -> Result, String> { + let catalog = catalog_state()?; + Ok(catalog + .display_names + .get(&normalize_id(model_name)) + .cloned()) +} + +pub(crate) fn supports_claude_code_1m(model_name: &str) -> Result { + let catalog = catalog_state()?; + Ok(catalog + .claude_code_1m_models + .contains(&normalize_id(model_name))) +} + +pub(crate) fn claude_code_effort_level_for(model_name: &str) -> Result, String> { + let catalog = catalog_state()?; + Ok(catalog + .claude_code_effort_levels + .get(&normalize_id(model_name)) + .cloned()) +} + +fn catalog_state() -> Result<&'static ClaudeCatalog, String> { + CATALOG_STATE + .get_or_init(|| parse_catalog(MODEL_CATALOG_JSON)) + .as_ref() + .map_err(Clone::clone) +} + +fn parse_catalog(content: &str) -> Result { + let root: Value = serde_json::from_str(content) + .map_err(|error| format!("解析内置 Claude model-catalog.json 失败: {error}"))?; + let root = root + .as_object() + .ok_or_else(|| "内置 Claude model-catalog.json 根节点必须是对象".to_string())?; + let fallback_context_window = root + .get("fallback_model") + .and_then(Value::as_object) + .and_then(|model| model.get("context_window")) + .and_then(positive_u64) + .ok_or_else(|| { + "内置 Claude model-catalog.json 缺少有效的 fallback_model.context_window".to_string() + })?; + let models = root + .get("models") + .and_then(Value::as_array) + .ok_or_else(|| "内置 Claude model-catalog.json 必须包含 models 数组".to_string())?; + let mut context_windows = HashMap::with_capacity(models.len()); + let mut display_names = HashMap::with_capacity(models.len()); + let mut claude_code_1m_models = HashSet::with_capacity(models.len()); + let mut claude_code_effort_levels = HashMap::with_capacity(models.len()); + let mut model_ids = HashSet::with_capacity(models.len()); + for (index, model) in models.iter().enumerate() { + let model = model.as_object().ok_or_else(|| { + format!( + "内置 Claude model-catalog.json 第 {} 个模型必须是对象", + index + 1 + ) + })?; + let slug = model + .get("slug") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "内置 Claude model-catalog.json 第 {} 个模型的 slug 不能为空", + index + 1 + ) + })?; + let key = normalize_id(slug); + if !model_ids.insert(key.clone()) { + return Err(format!( + "内置 Claude model-catalog.json 模型 slug 大小写重复: {slug}" + )); + } + if let Some(context_window) = model.get("context_window").and_then(positive_u64) { + context_windows.insert(key.clone(), context_window); + } + if let Some(display_name) = model + .get("display_name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(slug)) + { + display_names.insert(key.clone(), display_name.to_string()); + } + if model + .get("claude_code_1m") + .and_then(Value::as_bool) + .unwrap_or(false) + { + claude_code_1m_models.insert(key.clone()); + } + if let Some(effort_level) = model + .get("claude_code_effort_level") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + claude_code_effort_levels.insert(key, effort_level.to_string()); + } + } + Ok(ClaudeCatalog { + fallback_context_window, + context_windows, + display_names, + claude_code_1m_models, + claude_code_effort_levels, + }) +} + +fn positive_u64(value: &Value) -> Option { + value + .as_u64() + .or_else(|| { + value + .as_i64() + .filter(|value| *value > 0) + .map(|value| value as u64) + }) + .or_else(|| value.as_str()?.trim().parse::().ok()) + .filter(|value| *value > 0) +} + +fn normalize_id(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::{parse_catalog, MODEL_CATALOG_JSON}; + + #[test] + fn bundled_catalog_marks_deepseek_v4_as_1m() { + let catalog = parse_catalog(MODEL_CATALOG_JSON).unwrap(); + assert_eq!(catalog.context_windows["deepseek-v4-flash"], 1_000_000); + assert_eq!(catalog.context_windows["deepseek-v4-pro"], 1_000_000); + assert!(catalog.claude_code_1m_models.contains("deepseek-v4-flash")); + assert!(catalog.claude_code_1m_models.contains("deepseek-v4-pro")); + assert_eq!( + catalog.claude_code_effort_levels["deepseek-v4-flash"], + "max" + ); + assert_eq!(catalog.claude_code_effort_levels["deepseek-v4-pro"], "max"); + assert!(!catalog.claude_code_1m_models.contains("gpt-5.6-luna")); + assert_eq!( + catalog.display_names["deepseek-v4-flash"], + "DeepSeek V4 Flash" + ); + assert_eq!(catalog.display_names["deepseek-v4-pro"], "DeepSeek V4 Pro"); + } + + #[test] + fn catalog_prefers_file_override_over_runtime_metadata() { + let catalog = parse_catalog( + r#"{ + "fallback_model": {"context_window": 200000}, + "models": [{ + "slug": "gpt-test", + "display_name": "GPT Test", + "context_window": 272000, + "claude_code_1m": true, + "claude_code_effort_level": "max" + }] + }"#, + ) + .unwrap(); + assert_eq!(catalog.context_windows["gpt-test"], 272000); + assert_eq!(catalog.display_names["gpt-test"], "GPT Test"); + assert!(catalog.claude_code_1m_models.contains("gpt-test")); + assert_eq!(catalog.claude_code_effort_levels["gpt-test"], "max"); + } + + #[test] + fn catalog_rejects_duplicate_model_ids() { + let error = parse_catalog( + r#"{ + "fallback_model": {"context_window": 200000}, + "models": [ + {"slug": "gpt-test", "context_window": 272000}, + {"slug": "GPT-TEST", "context_window": 128000} + ] + }"#, + ) + .unwrap_err(); + assert!(error.contains("slug 大小写重复")); + } + + #[test] + fn catalog_allows_model_entries_without_context_override() { + let catalog = parse_catalog( + r#"{ + "fallback_model": {"context_window": 200000}, + "models": [{"slug": "runtime-model"}] + }"#, + ) + .unwrap(); + assert!(catalog.context_windows.is_empty()); + assert!(catalog.display_names.is_empty()); + assert!(catalog.claude_code_1m_models.is_empty()); + assert!(catalog.claude_code_effort_levels.is_empty()); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b0d8a2a..4d0dcd4 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod claude_catalog; mod codex_catalog; mod codex_sessions; mod usage; @@ -2287,6 +2288,17 @@ async fn fetch_prepared_agent_models( if matches!(client, AgentClient::ClaudeCode | AgentClient::ClaudeDesktop) { let content = fetch_management_config_yaml(config).await?; mark_configured_agent_model_aliases(&mut models, &content)?; + for model in &mut models { + model.context_window = Some(claude_catalog::context_window_for( + &model.name, + model.context_window, + )?); + if !model.is_alias { + if let Some(display_name) = claude_catalog::display_name_for(&model.name)? { + model.alias = Some(display_name); + } + } + } } Ok(PreparedAgentModels { models, @@ -2754,24 +2766,38 @@ async fn launch_agent( .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 max_context_override = + (!claude_code_model_supports_1m(&prepared.models, &mappings.sonnet)?) + .then_some(max_context_tokens); { 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)?; + update_claude_code_context_window(&claude_config_path, max_context_override)?; } let environment = claude_code_launch_environment( &format!("http://127.0.0.1:{port}"), effective_agent_api_key(&config), &mappings, + &prepared.models, max_context_tokens, - ); + )?; + let mut environment_to_remove = vec!["ANTHROPIC_API_KEY"]; + if !environment + .iter() + .any(|(key, _)| key == "CLAUDE_CODE_EFFORT_LEVEL") + { + environment_to_remove.push("CLAUDE_CODE_EFFORT_LEVEL"); + } + if max_context_override.is_none() { + environment_to_remove.push(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV); + } return launch_cli_agent( &executable, client.name(), &selected_working_directory, &environment, - &["ANTHROPIC_API_KEY"], + &environment_to_remove, ); } launch_cli_agent(&executable, client.name(), &home, &[], &[]) @@ -5051,6 +5077,7 @@ fn inspect_claude_agent_config( .or_else(|| root.get("model").and_then(serde_json::Value::as_str)) .map(str::trim) .filter(|value| !value.is_empty()) + .map(strip_claude_code_context_suffix) .map(str::to_string); Ok((configured, model)) } @@ -5080,6 +5107,7 @@ fn inspect_claude_code_model_mappings( .map(str::trim) .filter(|value| !value.is_empty()) .or(fallback) + .map(strip_claude_code_context_suffix) .map(str::to_string) }; let Some(opus) = read_model("ANTHROPIC_DEFAULT_OPUS_MODEL") else { @@ -5098,7 +5126,36 @@ fn inspect_claude_code_model_mappings( })) } +fn strip_claude_code_context_suffix(value: &str) -> &str { + let mut value = value.trim(); + loop { + let Some(suffix_start) = value.len().checked_sub(4) else { + return value; + }; + if !value[suffix_start..].eq_ignore_ascii_case("[1m]") { + return value; + } + value = value[..suffix_start].trim_end(); + } +} + +fn agent_model_source_name<'a>(models: &'a [AgentModelOption], model_name: &'a str) -> &'a str { + let model_name = strip_claude_code_context_suffix(model_name); + let Some(model) = models + .iter() + .find(|model| model.name.eq_ignore_ascii_case(model_name)) + else { + return model_name; + }; + if model.is_alias { + model.alias.as_deref().unwrap_or(model.name.as_str()) + } else { + model.name.as_str() + } +} + fn agent_model_context_window(models: &[AgentModelOption], model_name: &str) -> Option { + let model_name = strip_claude_code_context_suffix(model_name); let selected = models .iter() .find(|model| model.name.eq_ignore_ascii_case(model_name))?; @@ -5126,46 +5183,169 @@ 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()), + let model = mappings.sonnet.as_str(); + let context_window = agent_model_context_window(models, model) + .ok_or_else(|| format!("CPA 模型 API 未返回 Claude Code 主模型 {model} 的上下文窗口"))?; + if context_window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW + && !claude_code_model_supports_1m(models, model)? + { + return Ok(DEFAULT_CLAUDE_CONTEXT_WINDOW); + } + Ok(context_window) +} + +fn agent_model_display_name<'a>(models: &'a [AgentModelOption], model_name: &'a str) -> &'a str { + let model_name = strip_claude_code_context_suffix(model_name); + let Some(model) = models + .iter() + .find(|model| model.name.eq_ignore_ascii_case(model_name)) + else { + return model_name; + }; + if model.is_alias { + model.name.as_str() + } else { + model.alias.as_deref().unwrap_or(model.name.as_str()) + } +} + +fn claude_code_model_supports_1m( + models: &[AgentModelOption], + model_name: &str, +) -> Result { + claude_catalog::supports_claude_code_1m(agent_model_source_name(models, model_name)) +} + +fn claude_code_model_effort_level( + models: &[AgentModelOption], + model_name: &str, +) -> Result, String> { + claude_catalog::claude_code_effort_level_for(agent_model_source_name(models, model_name)) +} + +fn claude_code_model_setting( + models: &[AgentModelOption], + model_name: &str, + enable_1m_variant: bool, +) -> Result { + let model_name = strip_claude_code_context_suffix(model_name); + if enable_1m_variant && claude_code_model_supports_1m(models, model_name)? { + Ok(format!("{model_name}[1m]")) + } else { + Ok(model_name.to_string()) + } +} + +fn claude_code_model_settings( + mappings: &ClaudeDesktopModelMappings, + models: &[AgentModelOption], +) -> Result { + Ok(ClaudeDesktopModelMappings { + opus: claude_code_model_setting(models, &mappings.opus, true)?, + sonnet: claude_code_model_setting(models, &mappings.sonnet, true)?, + haiku: claude_code_model_setting(models, &mappings.haiku, false)?, + }) +} + +fn format_context_window(context_window: u64) -> String { + if context_window % 1_000_000 == 0 { + format!("{}M", context_window / 1_000_000) + } else if context_window % 1_000 == 0 { + format!("{}K", context_window / 1_000) + } else { + context_window.to_string() + } +} + +fn claude_code_model_presentation( + models: &[AgentModelOption], + model_name: &str, + role: Option<&str>, +) -> (String, String) { + let display_name = agent_model_display_name(models, model_name); + let context_window = + agent_model_context_window(models, model_name).unwrap_or(DEFAULT_CLAUDE_CONTEXT_WINDOW); + let context_label = format_context_window(context_window); + let name = match role { + Some(role) => format!("{display_name} ({role}, {context_label} context)"), + None => format!("{display_name} ({context_label} context)"), + }; + let description = format!("CPA model {model_name} - {context_label} context window"); + (name, description) +} + +fn claude_code_model_presentation_environment( + mappings: &ClaudeDesktopModelMappings, + models: &[AgentModelOption], +) -> Result, String> { + let opus = claude_code_model_presentation(models, &mappings.opus, Some("Opus mapping")); + let sonnet = claude_code_model_presentation(models, &mappings.sonnet, Some("Sonnet mapping")); + let haiku = claude_code_model_presentation(models, &mappings.haiku, Some("Haiku mapping")); + let fable = claude_code_model_presentation(models, &mappings.sonnet, Some("Fable mapping")); + let custom = claude_code_model_presentation(models, &mappings.sonnet, None); + let custom_model = claude_code_model_setting(models, &mappings.sonnet, true)?; + Ok([ + ("ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", opus.0), + ("ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION", opus.1), + ("ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", sonnet.0), + ("ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION", sonnet.1), + ("ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", haiku.0), + ("ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION", haiku.1), + ("ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", fable.0), + ("ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION", fable.1), + ("ANTHROPIC_CUSTOM_MODEL_OPTION", custom_model), + ("ANTHROPIC_CUSTOM_MODEL_OPTION_NAME", custom.0), + ("ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", custom.1), ] .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()) + .map(|(key, value)| (key.to_string(), value)) + .collect()) } fn claude_code_launch_environment( base_url: &str, api_key: &str, mappings: &ClaudeDesktopModelMappings, + models: &[AgentModelOption], max_context_tokens: u64, -) -> Vec<(String, String)> { +) -> Result, String> { + let model_settings = claude_code_model_settings(mappings, models)?; + let subagent_model = strip_claude_code_context_suffix(&mappings.haiku).to_string(); let mut environment = [ ("ANTHROPIC_BASE_URL", base_url), ("ANTHROPIC_AUTH_TOKEN", api_key), - ("ANTHROPIC_MODEL", mappings.sonnet.as_str()), - ("ANTHROPIC_DEFAULT_HAIKU_MODEL", mappings.haiku.as_str()), - ("ANTHROPIC_DEFAULT_SONNET_MODEL", mappings.sonnet.as_str()), - ("ANTHROPIC_DEFAULT_OPUS_MODEL", mappings.opus.as_str()), - ("ANTHROPIC_DEFAULT_FABLE_MODEL", mappings.sonnet.as_str()), + ("ANTHROPIC_MODEL", model_settings.sonnet.as_str()), + ( + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + model_settings.haiku.as_str(), + ), + ( + "ANTHROPIC_DEFAULT_SONNET_MODEL", + model_settings.sonnet.as_str(), + ), + ("ANTHROPIC_DEFAULT_OPUS_MODEL", model_settings.opus.as_str()), + ( + "ANTHROPIC_DEFAULT_FABLE_MODEL", + model_settings.sonnet.as_str(), + ), + ("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.as_str()), ] .into_iter() .map(|(key, value)| (key.to_string(), value.to_string())) .collect::>(); - environment.push(( - CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), - max_context_tokens.to_string(), - )); - environment + environment.extend(claude_code_model_presentation_environment( + mappings, models, + )?); + if let Some(effort_level) = claude_code_model_effort_level(models, &mappings.sonnet)? { + environment.push(("CLAUDE_CODE_EFFORT_LEVEL".to_string(), effort_level)); + } + if !claude_code_model_supports_1m(models, &mappings.sonnet)? { + environment.push(( + CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), + max_context_tokens.to_string(), + )); + } + Ok(environment) } fn inspect_claude_desktop_agent_config( @@ -5645,29 +5825,56 @@ fn build_claude_agent_config( .cloned() .unwrap_or_else(|| ClaudeDesktopModelMappings::all(model)); let max_context_tokens = claude_code_max_context_tokens(&mappings, models)?; + let use_max_context_override = !claude_code_model_supports_1m(models, &mappings.sonnet)?; + let model_settings = claude_code_model_settings(&mappings, models)?; + let subagent_model = strip_claude_code_context_suffix(&mappings.haiku).to_string(); + let effort_level = claude_code_model_effort_level(models, &mappings.sonnet)?; let env = ensure_json_object_entry(root, "env"); env.remove("ANTHROPIC_API_KEY"); + env.remove("CLAUDE_CODE_EFFORT_LEVEL"); + env.remove(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV); for (key, value) in [ ("ANTHROPIC_BASE_URL", base_url), ("ANTHROPIC_AUTH_TOKEN", api_key), - ("ANTHROPIC_MODEL", mappings.sonnet.as_str()), - ("ANTHROPIC_DEFAULT_HAIKU_MODEL", mappings.haiku.as_str()), - ("ANTHROPIC_DEFAULT_SONNET_MODEL", mappings.sonnet.as_str()), - ("ANTHROPIC_DEFAULT_OPUS_MODEL", mappings.opus.as_str()), - ("ANTHROPIC_DEFAULT_FABLE_MODEL", mappings.sonnet.as_str()), + ("ANTHROPIC_MODEL", model_settings.sonnet.as_str()), + ( + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + model_settings.haiku.as_str(), + ), + ( + "ANTHROPIC_DEFAULT_SONNET_MODEL", + model_settings.sonnet.as_str(), + ), + ("ANTHROPIC_DEFAULT_OPUS_MODEL", model_settings.opus.as_str()), + ( + "ANTHROPIC_DEFAULT_FABLE_MODEL", + model_settings.sonnet.as_str(), + ), + ("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.as_str()), ] { env.insert( key.to_string(), 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()), - ); + if use_max_context_override { + env.insert( + CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), + serde_json::Value::String(max_context_tokens.to_string()), + ); + } + for (key, value) in claude_code_model_presentation_environment(&mappings, models)? { + env.insert(key, serde_json::Value::String(value)); + } + if let Some(effort_level) = effort_level { + env.insert( + "CLAUDE_CODE_EFFORT_LEVEL".to_string(), + serde_json::Value::String(effort_level), + ); + } root.insert( "model".to_string(), - serde_json::Value::String(mappings.sonnet), + serde_json::Value::String(model_settings.sonnet), ); let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone())) .map_err(|error| format!("生成 Claude Code 配置失败: {error}"))?; @@ -5692,7 +5899,10 @@ fn remove_claude_code_conflicting_api_key(path: &Path) -> Result { }) } -fn update_claude_code_context_window(path: &Path, max_context_tokens: u64) -> Result { +fn update_claude_code_context_window( + path: &Path, + max_context_tokens: Option, +) -> Result { update_agent_json_file(path, "Claude Code configuration", |root| { let managed = root .get("env") @@ -5703,8 +5913,11 @@ fn update_claude_code_context_window(path: &Path, max_context_tokens: u64) -> Re if !managed { return false; } - let expected = max_context_tokens.to_string(); let env = ensure_json_object_entry(root, "env"); + let Some(max_context_tokens) = max_context_tokens else { + return env.remove(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV).is_some(); + }; + let expected = max_context_tokens.to_string(); if env .get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV) .and_then(serde_json::Value::as_str) @@ -5814,10 +6027,7 @@ fn build_claude_desktop_profile( mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?; - root.insert( - "coworkEgressAllowedHosts".to_string(), - serde_json::json!(["*"]), - ); + root.remove("coworkEgressAllowedHosts"); root.insert( "disableDeploymentModeChooser".to_string(), serde_json::json!(true), @@ -5879,9 +6089,15 @@ fn claude_desktop_inference_model( route_model }), ); - if context_window.is_some_and(|window| window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW) { - entry.insert("supports1m".to_string(), serde_json::json!(true)); - entry.insert("prefer1m".to_string(), serde_json::json!(true)); + if let Some(context_window) = context_window { + entry.insert( + "contextWindow".to_string(), + serde_json::json!(context_window), + ); + if context_window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW { + entry.insert("supports1m".to_string(), serde_json::json!(true)); + entry.insert("prefer1m".to_string(), serde_json::json!(true)); + } } serde_json::Value::Object(entry) } @@ -19855,7 +20071,9 @@ model: #[test] fn claude_agent_config_preserves_existing_fields() { let rendered = build_claude_agent_config( - Some(r#"{"theme":"dark","env":{"KEEP":"yes","ANTHROPIC_API_KEY":"legacy-key"}}"#), + Some( + r#"{"theme":"dark","env":{"KEEP":"yes","ANTHROPIC_API_KEY":"legacy-key","CLAUDE_CODE_EFFORT_LEVEL":"max"}}"#, + ), "http://127.0.0.1:8317", DEFAULT_API_KEY, "claude-test", @@ -19871,9 +20089,46 @@ model: assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "http://127.0.0.1:8317"); assert_eq!(value["env"]["ANTHROPIC_AUTH_TOKEN"], DEFAULT_API_KEY); assert_eq!(value["env"]["ANTHROPIC_MODEL"], "claude-test"); + assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none()); + assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "claude-test"); + assert_eq!( + value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"], + "claude-test (200K context)" + ); assert_eq!(value["model"], "claude-test"); } + #[test] + fn claude_code_inspection_normalizes_1m_suffix() { + let directory = agent_test_home("claude-code-1m-inspection"); + let path = directory.join("settings.json"); + fs::write( + &path, + r#"{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", + "ANTHROPIC_AUTH_TOKEN": "test-key", + "ANTHROPIC_MODEL": "deepseek-v4-pro[1m]", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-pro[1m]", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1m]", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash" + }, + "model": "deepseek-v4-pro[1m]" + }"#, + ) + .unwrap(); + + let (configured, model) = inspect_claude_agent_config(&path, 8317, "test-key").unwrap(); + let mappings = inspect_claude_code_model_mappings(&path).unwrap().unwrap(); + + assert!(configured); + assert_eq!(model.as_deref(), Some("deepseek-v4-pro")); + assert_eq!(mappings.opus, "deepseek-v4-pro"); + assert_eq!(mappings.sonnet, "deepseek-v4-pro"); + assert_eq!(mappings.haiku, "deepseek-v4-flash"); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn claude_code_role_mappings_drive_settings_and_launch_environment() { let mappings = ClaudeDesktopModelMappings { @@ -19904,7 +20159,7 @@ model: name: mappings.haiku.clone(), alias: None, is_alias: false, - context_window: Some(512_000), + context_window: Some(128_000), }, ]; let rendered = build_claude_agent_config( @@ -19922,13 +20177,19 @@ model: 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"); + assert_eq!( + value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"], + "gpt-sonnet (272K context)" + ); let environment = claude_code_launch_environment( "http://127.0.0.1:8317", "test-key", &mappings, + &models, claude_code_max_context_tokens(&mappings, &models).unwrap(), - ); + ) + .unwrap(); let read = |key: &str| { environment .iter() @@ -19941,9 +20202,178 @@ 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_SUBAGENT_MODEL"), Some("gpt-haiku")); + assert_eq!(read("CLAUDE_CODE_EFFORT_LEVEL"), None); + assert_eq!( + read("ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"), + Some("gpt-sonnet (272K context)") + ); assert_eq!(read(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV), Some("272000")); } + #[test] + fn claude_code_deepseek_picker_shows_catalog_name_and_1m_context() { + let mappings = ClaudeDesktopModelMappings { + opus: "deepseek-v4-pro".to_string(), + sonnet: "deepseek-v4-pro".to_string(), + haiku: "deepseek-v4-flash".to_string(), + }; + let models = vec![ + AgentModelOption { + name: "deepseek-v4-pro".to_string(), + alias: Some("DeepSeek V4 Pro".to_string()), + is_alias: false, + context_window: Some(1_000_000), + }, + AgentModelOption { + name: "deepseek-v4-flash".to_string(), + alias: Some("DeepSeek V4 Flash".to_string()), + is_alias: false, + context_window: Some(1_000_000), + }, + ]; + let rendered = build_claude_agent_config( + None, + "http://127.0.0.1:8317", + "test-key", + "deepseek-v4-pro", + &models, + Some(&mappings), + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + + assert!(value["env"] + .get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV) + .is_none()); + assert_eq!(value["env"]["ANTHROPIC_MODEL"], "deepseek-v4-pro[1m]"); + assert_eq!( + value["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], + "deepseek-v4-pro[1m]" + ); + assert_eq!( + value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], + "deepseek-v4-pro[1m]" + ); + assert_eq!( + value["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"], + "deepseek-v4-flash" + ); + assert_eq!( + value["env"]["ANTHROPIC_DEFAULT_FABLE_MODEL"], + "deepseek-v4-pro[1m]" + ); + assert_eq!( + value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], + "deepseek-v4-flash" + ); + assert_eq!(value["env"]["CLAUDE_CODE_EFFORT_LEVEL"], "max"); + assert_eq!( + value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION"], + "deepseek-v4-pro[1m]" + ); + assert_eq!( + value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"], + "DeepSeek V4 Pro (1M context)" + ); + assert_eq!( + value["env"]["ANTHROPIC_DEFAULT_FABLE_MODEL_NAME"], + "DeepSeek V4 Pro (Fable mapping, 1M context)" + ); + assert_eq!(value["model"], "deepseek-v4-pro[1m]"); + + let environment = claude_code_launch_environment( + "http://127.0.0.1:8317", + "test-key", + &mappings, + &models, + claude_code_max_context_tokens(&mappings, &models).unwrap(), + ) + .unwrap(); + assert!(!environment + .iter() + .any(|(key, _)| key == CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV)); + } + + #[test] + fn claude_code_1m_suffix_requires_explicit_catalog_support() { + let models = vec![ + AgentModelOption { + name: "deepseek-v4-flash".to_string(), + alias: None, + is_alias: false, + context_window: Some(1_000_000), + }, + AgentModelOption { + name: "gpt-runtime-1m".to_string(), + alias: None, + is_alias: false, + context_window: Some(1_000_000), + }, + ]; + + assert_eq!( + claude_code_model_setting(&models, "deepseek-v4-flash", true).unwrap(), + "deepseek-v4-flash[1m]" + ); + assert_eq!( + claude_code_model_setting(&models, "deepseek-v4-flash", false).unwrap(), + "deepseek-v4-flash" + ); + assert_eq!( + claude_code_model_setting(&models, "gpt-runtime-1m", true).unwrap(), + "gpt-runtime-1m" + ); + assert_eq!( + claude_code_max_context_tokens( + &ClaudeDesktopModelMappings::all("gpt-runtime-1m"), + &models, + ) + .unwrap(), + DEFAULT_CLAUDE_CONTEXT_WINDOW + ); + } + + #[test] + fn claude_code_context_window_follows_primary_model_alias_source() { + let mappings = ClaudeDesktopModelMappings { + opus: "small-model".to_string(), + sonnet: "primary-alias".to_string(), + haiku: "large-model".to_string(), + }; + let models = vec![ + AgentModelOption { + name: mappings.opus.clone(), + alias: None, + is_alias: false, + context_window: Some(128_000), + }, + AgentModelOption { + name: "primary-model".to_string(), + alias: None, + is_alias: false, + context_window: Some(372_000), + }, + AgentModelOption { + name: mappings.sonnet.clone(), + alias: Some("primary-model".to_string()), + is_alias: true, + context_window: Some(200_000), + }, + AgentModelOption { + name: mappings.haiku.clone(), + alias: None, + is_alias: false, + context_window: Some(1_000_000), + }, + ]; + + assert_eq!( + claude_code_max_context_tokens(&mappings, &models).unwrap(), + 372_000 + ); + } + #[test] fn claude_code_uses_200k_when_cpa_context_metadata_is_missing() { let mappings = ClaudeDesktopModelMappings::all("custom-model"); @@ -19971,8 +20401,8 @@ 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()); + assert!(update_claude_code_context_window(&path, Some(272_000)).unwrap()); + assert!(!update_claude_code_context_window(&path, Some(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()); @@ -19981,6 +20411,26 @@ model: fs::remove_dir_all(directory).unwrap(); } + #[test] + fn claude_code_1m_variant_removes_global_context_override() { + let directory = agent_test_home("claude-code-remove-context-override"); + let path = directory.join("settings.json"); + fs::write( + &path, + r#"{"env":{"ANTHROPIC_BASE_URL":"http://127.0.0.1:8317","CLAUDE_CODE_MAX_CONTEXT_TOKENS":"1000000"}}"#, + ) + .unwrap(); + + assert!(update_claude_code_context_window(&path, None).unwrap()); + assert!(!update_claude_code_context_window(&path, None).unwrap()); + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert!(value["env"] + .get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV) + .is_none()); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn claude_code_working_directory_must_be_absolute_and_exist() { let directory = agent_test_home("claude-code-working-directory"); @@ -20175,7 +20625,7 @@ model: #[test] fn claude_desktop_config_builds_gateway_profile_and_index() { let profile = build_claude_desktop_profile( - Some(r#"{"keep":true}"#), + Some(r#"{"keep":true,"coworkEgressAllowedHosts":["*"]}"#), "http://127.0.0.1:8317", DEFAULT_API_KEY, "claude-sonnet-test", @@ -20193,6 +20643,7 @@ model: let meta: serde_json::Value = serde_json::from_str(&meta).unwrap(); assert_eq!(profile["keep"], true); + assert!(profile.get("coworkEgressAllowedHosts").is_none()); assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY); assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317"); assert_eq!( @@ -20257,12 +20708,14 @@ model: serde_json::json!([ { "name": CLAUDE_DESKTOP_OPUS_MODEL_ID, + "contextWindow": 1000000, "supports1m": true, "prefer1m": true }, - { "name": CLAUDE_DESKTOP_SONNET_MODEL_ID }, + { "name": CLAUDE_DESKTOP_SONNET_MODEL_ID, "contextWindow": 272000 }, { "name": CLAUDE_DESKTOP_HAIKU_MODEL_ID, + "contextWindow": 1000000, "supports1m": true, "prefer1m": true } @@ -20722,6 +21175,7 @@ oauth-model-alias: profile["inferenceModels"], serde_json::json!([{ "name": "gpt-high", + "contextWindow": 1_000_000, "supports1m": true, "prefer1m": true }]) diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index d60b1bb..2f3b00e 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -805,7 +805,7 @@ export const zhCN = { 'kernel.update.statusChecking': '正在检查', 'kernel.update.notYetChecked': '尚未检查', 'kernel.update.latest': '已是最新版本', - 'kernel.update.available': '可安装新版本', + 'kernel.update.available': '请在首页中关闭运行后安装新版本', 'appUpdate.eyebrow': '软件升级', 'appUpdate.title': '软件版本管理', 'appUpdate.current': '当前版本',