From e9470fbe1412f7277db8d910e9a0d95e394f73c7 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 11 Aug 2026 12:29:35 +0800 Subject: [PATCH] fix(agents): support Claude Desktop aliases for Codex OAuth models --- .../claude_models/model-catalog.json | 4 +- src-tauri/src/agents/commands.rs | 17 ++ src-tauri/src/agents/configuration.rs | 23 +- src-tauri/src/agents/discovery.rs | 76 +++-- src-tauri/src/claude_catalog.rs | 11 +- src-tauri/src/core_config/aliases.rs | 145 +++++++++- src-tauri/src/main.rs | 20 ++ src-tauri/src/tests/agent_configuration.rs | 263 ++++++++++-------- src/i18n/ja.ts | 10 +- src/i18n/locales/en.ts | 10 +- src/i18n/locales/zh-CN.ts | 10 +- src/pages/AgentsPage.tsx | 128 ++++++++- src/services/agentConfigurationDraft.ts | 8 +- src/styles.css | 108 +++++++ tests/agentModelPicker.test.ts | 36 ++- 15 files changed, 689 insertions(+), 180 deletions(-) diff --git a/src-tauri/resources/claude_models/model-catalog.json b/src-tauri/resources/claude_models/model-catalog.json index 7af4c33..d543533 100644 --- a/src-tauri/resources/claude_models/model-catalog.json +++ b/src-tauri/resources/claude_models/model-catalog.json @@ -54,8 +54,8 @@ { "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_effort_level": "max" }, - { "slug": "deepseek-v4-pro", "display_name": "DeepSeek V4 Pro", "context_window": 1000000, "claude_code_effort_level": "max" }, + { "slug": "deepseek-v4-flash", "display_name": "DeepSeek V4 Flash", "context_window": 1000000}, + { "slug": "deepseek-v4-pro", "display_name": "DeepSeek V4 Pro", "context_window": 1000000}, { "slug": "deepseek-chat", "display_name": "DeepSeek Chat" }, { "slug": "deepseek-reasoner", "display_name": "DeepSeek Reasoner" }, { "slug": "grok-4.5" }, diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 1b54388..4d543ba 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -677,6 +677,9 @@ pub(crate) fn resolve_claude_desktop_model_mappings( opus_1m: requested.opus_1m, sonnet_1m: requested.sonnet_1m, haiku_1m: requested.haiku_1m, + max_context_tokens: requested.max_context_tokens, + auto_compact_pct: requested.auto_compact_pct, + disable_auto_compact: requested.disable_auto_compact, })) } @@ -690,6 +693,17 @@ pub(crate) fn resolve_claude_code_model_mappings( return Ok(None); } let requested = requested.unwrap_or_else(|| ClaudeDesktopModelMappings::all(selected_model)); + if !(100_000..=1_000_000).contains(&requested.max_context_tokens) { + return Err("Claude Code 最大窗口必须介于 100000 和 1000000 之间".to_string()); + } + if !(1..=100).contains(&requested.auto_compact_pct) { + return Err("Claude Code 触发压缩百分比必须介于 1 和 100 之间".to_string()); + } + let max_context_tokens = if requested.opus_1m || requested.sonnet_1m || requested.haiku_1m { + CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW + } else { + requested.max_context_tokens + }; let resolve = |model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?); Ok(Some(ClaudeDesktopModelMappings { @@ -699,6 +713,9 @@ pub(crate) fn resolve_claude_code_model_mappings( opus_1m: requested.opus_1m, sonnet_1m: requested.sonnet_1m, haiku_1m: requested.haiku_1m, + max_context_tokens, + auto_compact_pct: requested.auto_compact_pct, + disable_auto_compact: requested.disable_auto_compact, })) } diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 0979917..e527b37 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -220,8 +220,7 @@ pub(crate) 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 use_max_context_override = !mappings.sonnet_1m; + let max_context_tokens = mappings.max_context_tokens; let model_settings = claude_code_model_settings(&mappings); let subagent_model = model_settings.haiku.clone(); let effort_level = claude_code_model_effort_level(models, &mappings.sonnet)?; @@ -229,6 +228,8 @@ pub(crate) fn build_claude_agent_config( env.remove("ANTHROPIC_API_KEY"); env.remove("CLAUDE_CODE_EFFORT_LEVEL"); env.remove(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV); + env.remove(CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV); + env.remove(DISABLE_AUTO_COMPACT_ENV); for (key, value) in [ ("ANTHROPIC_BASE_URL", base_url), ("ANTHROPIC_AUTH_TOKEN", api_key), @@ -253,10 +254,18 @@ pub(crate) fn build_claude_agent_config( serde_json::Value::String(value.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()), + ); + env.insert( + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV.to_string(), + serde_json::Value::String(mappings.auto_compact_pct.to_string()), + ); + if mappings.disable_auto_compact { env.insert( - CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), - serde_json::Value::String(max_context_tokens.to_string()), + DISABLE_AUTO_COMPACT_ENV.to_string(), + serde_json::Value::String("1".to_string()), ); } for (key, value) in claude_code_model_presentation_environment(&mappings, models)? { @@ -647,6 +656,8 @@ pub(crate) fn remove_claude_code_managed_configuration( "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL", CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV, + DISABLE_AUTO_COMPACT_ENV, ] { env.remove(key); } @@ -1018,6 +1029,8 @@ pub(crate) fn build_restored_claude_code_config( "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL", CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV, + DISABLE_AUTO_COMPACT_ENV, ] { restore_json_key(env, original_env, key); } diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 6b2923e..b3e3af2 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -1743,40 +1743,58 @@ pub(crate) fn inspect_claude_code_model_mappings( .map(str::trim) .filter(|value| !value.is_empty()); let read_model = |key: &str| { - let value = env.get(key) + let value = env + .get(key) .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .or(fallback)?; - Some(( - strip_claude_code_context_suffix(value).to_string(), - has_claude_code_context_suffix(value), - )) + let normalized = strip_claude_code_context_suffix(value); + Some((normalized.to_string(), normalized.len() != value.len())) }; - let Some((opus, opus_1m)) = read_model("ANTHROPIC_DEFAULT_OPUS_MODEL") else { + let Some((opus, opus_had_1m)) = read_model("ANTHROPIC_DEFAULT_OPUS_MODEL") else { return Ok(None); }; - let Some((sonnet, sonnet_1m)) = read_model("ANTHROPIC_DEFAULT_SONNET_MODEL") else { + let Some((sonnet, sonnet_had_1m)) = read_model("ANTHROPIC_DEFAULT_SONNET_MODEL") else { return Ok(None); }; - let Some((haiku, haiku_1m)) = read_model("ANTHROPIC_DEFAULT_HAIKU_MODEL") else { + let Some((haiku, haiku_had_1m)) = read_model("ANTHROPIC_DEFAULT_HAIKU_MODEL") else { return Ok(None); }; + let legacy_1m = opus_had_1m || sonnet_had_1m || haiku_had_1m; + let max_context_tokens = env + .get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV) + .and_then(serde_json::Value::as_str) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or_else(|| { + if legacy_1m { + CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW + } else { + default_claude_code_max_context_tokens() + } + }); + let auto_compact_pct = env + .get(CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV) + .and_then(serde_json::Value::as_str) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or_else(default_claude_auto_compact_pct); + let disable_auto_compact = env + .get(DISABLE_AUTO_COMPACT_ENV) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); Ok(Some(ClaudeDesktopModelMappings { opus, sonnet, haiku, - opus_1m, - sonnet_1m, - haiku_1m, + opus_1m: opus_had_1m, + sonnet_1m: sonnet_had_1m, + haiku_1m: haiku_had_1m, + max_context_tokens, + auto_compact_pct, + disable_auto_compact, })) } -pub(crate) fn has_claude_code_context_suffix(value: &str) -> bool { - let value = value.trim(); - value.len() >= 4 && value[value.len() - 4..].eq_ignore_ascii_case("[1m]") -} - pub(crate) fn strip_claude_code_context_suffix(value: &str) -> &str { let mut value = value.trim(); loop { @@ -1849,15 +1867,10 @@ pub(crate) fn claude_effective_context_window( .max(CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW), ); } - context_window.map(|context_window| { - if context_window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW { - DEFAULT_CLAUDE_CONTEXT_WINDOW - } else { - context_window - } - }) + context_window } +#[allow(dead_code)] pub(crate) fn claude_code_max_context_tokens( mappings: &ClaudeDesktopModelMappings, models: &[AgentModelOption], @@ -1893,10 +1906,7 @@ pub(crate) fn claude_code_model_effort_level( claude_catalog::claude_code_effort_level_for(agent_model_source_name(models, model_name)) } -pub(crate) fn claude_code_model_setting( - model_name: &str, - enable_1m_variant: bool, -) -> String { +pub(crate) fn claude_code_model_setting(model_name: &str, enable_1m_variant: bool) -> String { let model_name = strip_claude_code_context_suffix(model_name); if enable_1m_variant { format!("{model_name}[1m]") @@ -1915,6 +1925,9 @@ pub(crate) fn claude_code_model_settings( opus_1m: mappings.opus_1m, sonnet_1m: mappings.sonnet_1m, haiku_1m: mappings.haiku_1m, + max_context_tokens: mappings.max_context_tokens, + auto_compact_pct: mappings.auto_compact_pct, + disable_auto_compact: mappings.disable_auto_compact, } } @@ -1932,10 +1945,12 @@ pub(crate) fn claude_code_model_presentation( models: &[AgentModelOption], model_name: &str, enable_1m: bool, + context_window_override: Option, role: Option<&str>, ) -> (String, String) { let display_name = agent_model_display_name(models, model_name); - let context_window = claude_effective_context_window(models, model_name, enable_1m) + let context_window = context_window_override + .or_else(|| claude_effective_context_window(models, model_name, enable_1m)) .unwrap_or(DEFAULT_CLAUDE_CONTEXT_WINDOW); let context_label = format_context_window(context_window); let name = match role { @@ -1954,30 +1969,35 @@ pub(crate) fn claude_code_model_presentation_environment( models, &mappings.opus, mappings.opus_1m, + Some(mappings.max_context_tokens), Some("Opus mapping"), ); let sonnet = claude_code_model_presentation( models, &mappings.sonnet, mappings.sonnet_1m, + Some(mappings.max_context_tokens), Some("Sonnet mapping"), ); let haiku = claude_code_model_presentation( models, &mappings.haiku, mappings.haiku_1m, + Some(mappings.max_context_tokens), Some("Haiku mapping"), ); let fable = claude_code_model_presentation( models, &mappings.sonnet, mappings.sonnet_1m, + Some(mappings.max_context_tokens), Some("Fable mapping"), ); let custom = claude_code_model_presentation( models, &mappings.sonnet, mappings.sonnet_1m, + Some(mappings.max_context_tokens), None, ); let custom_model = claude_code_model_setting(&mappings.sonnet, mappings.sonnet_1m); diff --git a/src-tauri/src/claude_catalog.rs b/src-tauri/src/claude_catalog.rs index 03472fa..d879e06 100644 --- a/src-tauri/src/claude_catalog.rs +++ b/src-tauri/src/claude_catalog.rs @@ -150,11 +150,12 @@ mod tests { 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_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_effort_levels + .contains_key("deepseek-v4-flash")); + assert!(!catalog + .claude_code_effort_levels + .contains_key("deepseek-v4-pro")); assert_eq!( catalog.display_names["deepseek-v4-flash"], "DeepSeek V4 Flash" diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 1e48bbd..e93c6be 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -70,7 +70,20 @@ pub(crate) async fn ensure_claude_desktop_model_aliases( models: &[AgentModelOption], ) -> Result<(), String> { let content = fetch_management_config_yaml(config).await?; - let updated = ensure_claude_desktop_model_aliases_in_yaml(&content, mappings, models)?; + let updated = match ensure_claude_desktop_model_aliases_in_yaml(&content, mappings, models) { + Ok(updated) => updated, + Err(source_error) => { + let definitions = fetch_codex_model_definitions(config) + .await + .map_err(|error| format!("{source_error};{error}"))?; + ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml( + &content, + mappings, + models, + &definitions, + )? + } + }; if updated != content { put_management_config_yaml(config, &updated).await?; } @@ -92,6 +105,15 @@ pub(crate) fn ensure_claude_desktop_model_aliases_in_yaml( content: &str, mappings: &ClaudeDesktopModelMappings, models: &[AgentModelOption], +) -> Result { + ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml(content, mappings, models, &[]) +} + +pub(crate) fn ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml( + content: &str, + mappings: &ClaudeDesktopModelMappings, + models: &[AgentModelOption], + codex_oauth_models: &[CodexModelDefinition], ) -> Result { let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; @@ -113,7 +135,7 @@ pub(crate) fn ensure_claude_desktop_model_aliases_in_yaml( remove_managed_claude_model_alias(root, alias)?; } } else { - ensure_claude_desktop_model_alias(root, source_model, alias)?; + ensure_claude_desktop_model_alias(root, source_model, alias, codex_oauth_models)?; } } render_updated_core_yaml(&mut document, updated) @@ -167,6 +189,7 @@ pub(crate) fn remove_managed_claude_model_alias( changed |= models.len() != before; } } + changed |= remove_oauth_claude_model_alias(root, alias, true)?; Ok(changed) } @@ -200,6 +223,51 @@ pub(crate) fn remove_existing_claude_model_alias( changed |= models.len() != before; } } + changed |= remove_oauth_claude_model_alias(root, alias, false)?; + Ok(changed) +} + +pub(crate) fn remove_oauth_claude_model_alias( + root: &mut serde_norway::Mapping, + alias: &str, + managed_only: bool, +) -> Result { + let Some(oauth_aliases) = yaml_mapping_value_mut(root, "oauth-model-alias") else { + return Ok(false); + }; + let oauth_aliases = oauth_aliases + .as_mapping_mut() + .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; + let mut changed = false; + let mut empty_channels = Vec::new(); + for (channel, entries) in oauth_aliases.iter_mut() { + let channel_name = channel.as_str().unwrap_or("unknown"); + let entries = entries + .as_sequence_mut() + .ok_or_else(|| format!("oauth-model-alias.{channel_name} 必须是数组"))?; + let before = entries.len(); + entries.retain(|entry| { + 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)) + } + }); + if entries.len() != before { + changed = true; + if entries.is_empty() { + empty_channels.push(channel.clone()); + } + } + } + for channel in empty_channels { + oauth_aliases.remove(&channel); + } + let remove_oauth_section = changed && oauth_aliases.is_empty(); + if remove_oauth_section { + root.remove(yaml_key("oauth-model-alias")); + } Ok(changed) } @@ -232,7 +300,7 @@ pub(crate) fn configured_managed_claude_alias_exists( root: &serde_norway::Mapping, alias: &str, ) -> bool { - MODEL_ALIAS_CONFIG_SECTIONS.iter().any(|section| { + let configured_provider_alias = MODEL_ALIAS_CONFIG_SECTIONS.iter().any(|section| { yaml_mapping_value(root, section) .and_then(serde_norway::Value::as_sequence) .into_iter() @@ -242,13 +310,22 @@ pub(crate) fn configured_managed_claude_alias_exists( .filter_map(serde_norway::Value::as_sequence) .flatten() .any(|model| is_managed_claude_model_alias(model, alias)) - }) + }); + configured_provider_alias + || yaml_mapping_value(root, "oauth-model-alias") + .and_then(serde_norway::Value::as_mapping) + .into_iter() + .flat_map(|channels| channels.values()) + .filter_map(serde_norway::Value::as_sequence) + .flatten() + .any(|model| is_managed_claude_model_alias(model, alias)) } pub(crate) fn ensure_claude_desktop_model_alias( root: &mut serde_norway::Mapping, source_model: &str, alias: &str, + codex_oauth_models: &[CodexModelDefinition], ) -> Result<(), String> { if let Some((existing_source, _)) = configured_model_client_identity(root, alias) { if existing_source.eq_ignore_ascii_case(source_model) @@ -261,6 +338,13 @@ pub(crate) fn ensure_claude_desktop_model_alias( if append_claude_desktop_model_alias(root, source_model, alias)? { return Ok(()); } + if codex_oauth_models + .iter() + .any(|model| model.id.eq_ignore_ascii_case(source_model)) + { + append_managed_codex_oauth_model_alias(root, source_model, alias)?; + return Ok(()); + } Err(format!( "无法确定模型 {source_model} 的 CPA 配置来源,无法创建 Claude Desktop 别名 {alias}" )) @@ -296,6 +380,24 @@ pub(crate) fn configured_model_client_identity( } } } + if let Some(oauth_aliases) = + yaml_mapping_value(root, "oauth-model-alias").and_then(serde_norway::Value::as_mapping) + { + for entries in oauth_aliases + .values() + .filter_map(serde_norway::Value::as_sequence) + { + for model in entries { + let Some((source, configured_client_model, _)) = configured_model_identity(model) + else { + continue; + }; + if configured_client_model.eq_ignore_ascii_case(client_model) { + return Some((source, configured_client_model)); + } + } + } + } None } @@ -359,6 +461,41 @@ pub(crate) fn append_claude_desktop_model_alias( Ok(false) } +pub(crate) fn append_managed_codex_oauth_model_alias( + root: &mut serde_norway::Mapping, + source_model: &str, + alias: &str, +) -> Result<(), String> { + let oauth_aliases = root + .entry(yaml_key("oauth-model-alias")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; + let codex_aliases = oauth_aliases + .entry(yaml_key("codex")) + .or_insert_with(|| serde_norway::Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; + let display_name = managed_claude_alias_display_name(alias) + .ok_or_else(|| format!("不支持的 Claude 托管别名: {alias}"))?; + let mut alias_mapping = serde_norway::Mapping::new(); + alias_mapping.insert( + yaml_key("name"), + serde_norway::Value::String(source_model.to_string()), + ); + alias_mapping.insert( + yaml_key("alias"), + serde_norway::Value::String(alias.to_string()), + ); + alias_mapping.insert(yaml_key("fork"), serde_norway::Value::Bool(true)); + alias_mapping.insert( + yaml_key("display-name"), + serde_norway::Value::String(display_name.to_string()), + ); + codex_aliases.push(serde_norway::Value::Mapping(alias_mapping)); + Ok(()) +} + pub(crate) async fn fetch_codex_model_definitions( config: &GuiConfigFile, ) -> Result, String> { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b815395..0c6ee87 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -142,6 +142,9 @@ const MANAGED_CLAUDE_HAIKU_ALIAS_DISPLAY_NAME: &str = 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 CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV: &str = "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"; +const DISABLE_AUTO_COMPACT_ENV: &str = "DISABLE_AUTO_COMPACT"; +const DEFAULT_CLAUDE_AUTO_COMPACT_PCT: u8 = 100; const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[ "codex-api-key", "openai-compatibility", @@ -788,6 +791,20 @@ struct ClaudeDesktopModelMappings { sonnet_1m: bool, #[serde(default)] haiku_1m: bool, + #[serde(default = "default_claude_code_max_context_tokens")] + max_context_tokens: u64, + #[serde(default = "default_claude_auto_compact_pct")] + auto_compact_pct: u8, + #[serde(default)] + disable_auto_compact: bool, +} + +fn default_claude_code_max_context_tokens() -> u64 { + DEFAULT_CLAUDE_CONTEXT_WINDOW +} + +fn default_claude_auto_compact_pct() -> u8 { + DEFAULT_CLAUDE_AUTO_COMPACT_PCT } impl ClaudeDesktopModelMappings { @@ -799,6 +816,9 @@ impl ClaudeDesktopModelMappings { opus_1m: false, sonnet_1m: false, haiku_1m: false, + max_context_tokens: default_claude_code_max_context_tokens(), + auto_compact_pct: default_claude_auto_compact_pct(), + disable_auto_compact: false, } } } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 659187b..2cae070 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -22,6 +22,9 @@ fn claude_agent_config_preserves_existing_fields() { 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_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "200000"); + assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "100"); + assert!(value["env"].get(DISABLE_AUTO_COMPACT_ENV).is_none()); assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none()); assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "claude-test"); assert_eq!( @@ -32,7 +35,7 @@ fn claude_agent_config_preserves_existing_fields() { } #[test] -fn claude_code_inspection_normalizes_1m_suffix() { +fn claude_code_inspection_normalizes_legacy_1m_suffix() { let directory = agent_test_home("claude-code-1m-inspection"); let path = directory.join("settings.json"); fs::write( @@ -62,19 +65,23 @@ fn claude_code_inspection_normalizes_1m_suffix() { assert!(mappings.opus_1m); assert!(mappings.sonnet_1m); assert!(!mappings.haiku_1m); + assert_eq!(mappings.max_context_tokens, 1_000_000); + assert_eq!(mappings.auto_compact_pct, 100); + assert!(!mappings.disable_auto_compact); fs::remove_dir_all(directory).unwrap(); } #[test] fn claude_mapping_legacy_json_defaults_1m_preferences_off() { - let mappings: ClaudeDesktopModelMappings = serde_json::from_str( - r#"{"opus":"model-a","sonnet":"model-b","haiku":"model-c"}"#, - ) - .unwrap(); + let mappings: ClaudeDesktopModelMappings = + serde_json::from_str(r#"{"opus":"model-a","sonnet":"model-b","haiku":"model-c"}"#).unwrap(); assert!(!mappings.opus_1m); assert!(!mappings.sonnet_1m); assert!(!mappings.haiku_1m); + assert_eq!(mappings.max_context_tokens, 200_000); + assert_eq!(mappings.auto_compact_pct, 100); + assert!(!mappings.disable_auto_compact); } #[test] @@ -86,6 +93,9 @@ fn claude_code_role_mappings_drive_settings() { opus_1m: false, sonnet_1m: false, haiku_1m: false, + max_context_tokens: 272_000, + auto_compact_pct: 80, + disable_auto_compact: false, }; let models = vec![ AgentModelOption { @@ -135,176 +145,98 @@ fn claude_code_role_mappings_drive_settings() { } #[test] -fn claude_code_deepseek_picker_shows_catalog_name_and_1m_context() { +fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { let mappings = ClaudeDesktopModelMappings { - opus: "deepseek-v4-pro".to_string(), - sonnet: "deepseek-v4-pro".to_string(), - haiku: "deepseek-v4-flash".to_string(), + opus: "custom-pro".to_string(), + sonnet: "custom-pro".to_string(), + haiku: "custom-flash".to_string(), opus_1m: true, sonnet_1m: true, haiku_1m: false, + max_context_tokens: 1_000_000, + auto_compact_pct: 75, + disable_auto_compact: true, }; let models = vec![ AgentModelOption { - name: "deepseek-v4-pro".to_string(), - alias: Some("DeepSeek V4 Pro".to_string()), + name: "custom-pro".to_string(), + alias: Some("Custom Pro".to_string()), is_alias: false, - context_window: Some(1_000_000), + context_window: Some(200_000), }, AgentModelOption { - name: "deepseek-v4-flash".to_string(), - alias: Some("DeepSeek V4 Flash".to_string()), + name: "custom-flash".to_string(), + alias: Some("Custom Flash".to_string()), is_alias: false, - context_window: Some(1_000_000), + context_window: Some(200_000), }, ]; let rendered = build_claude_agent_config( None, "http://127.0.0.1:8317", "test-key", - "deepseek-v4-pro", + "custom-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"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "1000000"); + assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "75"); + assert_eq!(value["env"][DISABLE_AUTO_COMPACT_ENV], "1"); + assert_eq!(value["env"]["ANTHROPIC_MODEL"], "custom-pro[1m]"); assert_eq!( value["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], - "deepseek-v4-pro[1m]" + "custom-pro[1m]" ); assert_eq!( value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], - "deepseek-v4-pro[1m]" + "custom-pro[1m]" ); assert_eq!( value["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"], - "deepseek-v4-flash" + "custom-flash" ); assert_eq!( value["env"]["ANTHROPIC_DEFAULT_FABLE_MODEL"], - "deepseek-v4-pro[1m]" + "custom-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"]["CLAUDE_CODE_SUBAGENT_MODEL"], "custom-flash"); + assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none()); assert_eq!( value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION"], - "deepseek-v4-pro[1m]" + "custom-pro[1m]" ); assert_eq!( value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"], - "DeepSeek V4 Pro (1M context)" + "Custom Pro (1M context)" ); assert_eq!( value["env"]["ANTHROPIC_DEFAULT_FABLE_MODEL_NAME"], - "DeepSeek V4 Pro (Fable mapping, 1M context)" + "Custom Pro (Fable mapping, 1M context)" ); - assert_eq!(value["model"], "deepseek-v4-pro[1m]"); + assert_eq!(value["model"], "custom-pro[1m]"); } #[test] -fn claude_code_1m_suffix_follows_user_preference() { - 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("deepseek-v4-flash", true), - "deepseek-v4-flash[1m]" - ); - assert_eq!( - claude_code_model_setting("deepseek-v4-flash", false), - "deepseek-v4-flash" - ); - assert_eq!( - claude_code_model_setting("gpt-runtime-1m", true), - "gpt-runtime-1m[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(), - opus_1m: false, - sonnet_1m: false, - haiku_1m: false, - }; - 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"); +fn claude_desktop_keeps_original_context_when_1m_is_off() { let models = vec![AgentModelOption { - name: "custom-model".to_string(), + name: "runtime-model".to_string(), alias: None, is_alias: false, - context_window: None, + context_window: Some(1_000_000), }]; - - assert_eq!( - claude_code_max_context_tokens(&mappings, &models).unwrap(), - DEFAULT_CLAUDE_CONTEXT_WINDOW + let entry = claude_desktop_inference_model( + CLAUDE_DESKTOP_SONNET_MODEL_ID, + "runtime-model", + false, + &models, ); + + assert_eq!(entry["contextWindow"], 1_000_000); + assert!(entry.get("supports1m").is_none()); + assert!(entry.get("prefer1m").is_none()); } #[test] @@ -534,6 +466,7 @@ fn claude_desktop_profile_keeps_non_claude_models_internal() { opus_1m: true, sonnet_1m: false, haiku_1m: true, + ..ClaudeDesktopModelMappings::all("") }; let models = vec![ AgentModelOption { @@ -860,6 +793,7 @@ fn claude_desktop_aliases_expose_role_routes_only() { opus_1m: false, sonnet_1m: false, haiku_1m: false, + ..ClaudeDesktopModelMappings::all("") }; let available_models = test_agent_models(&["gpt-5.6-sol"]); let rendered = @@ -911,6 +845,66 @@ fn claude_desktop_aliases_expose_role_routes_only() { ); } +#[test] +fn claude_desktop_aliases_support_codex_oauth_models() { + let input = "port: 8317\n"; + let mappings = ClaudeDesktopModelMappings::all("gpt-5.6-sol"); + let available_models = test_agent_models(&["gpt-5.6-sol"]); + let definitions = vec![CodexModelDefinition { + id: "gpt-5.6-sol".to_string(), + display_name: None, + description: None, + context_window: Some(372_000), + reasoning_levels: vec!["high".to_string()], + supports_tools: Some(true), + }]; + + let rendered = ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml( + input, + &mappings, + &available_models, + &definitions, + ) + .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, "codex")) + .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 == "gpt-5.6-sol") + })); + assert!(aliases.iter().all(|entry| { + entry + .as_mapping() + .and_then(|entry| yaml_mapping_value(entry, "fork")) + == Some(&serde_norway::Value::Bool(true)) + })); + assert_eq!( + ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml( + &rendered, + &mappings, + &available_models, + &definitions, + ) + .unwrap(), + rendered + ); + + let cleaned = remove_managed_claude_model_aliases_in_yaml(&rendered).unwrap(); + let cleaned: serde_norway::Value = serde_norway::from_str(&cleaned).unwrap(); + let cleaned = cleaned.as_mapping().unwrap(); + assert!(yaml_mapping_value(cleaned, "oauth-model-alias").is_none()); + assert_eq!( + yaml_mapping_value(cleaned, "port").and_then(serde_norway::Value::as_i64), + Some(8317) + ); +} + #[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"; @@ -962,6 +956,27 @@ fn claude_alias_cleanup_does_not_delete_user_owned_routes() { assert!(configured_model_client_identity(root, CLAUDE_DESKTOP_SONNET_MODEL_ID).is_none()); } +#[test] +fn claude_alias_cleanup_preserves_user_owned_oauth_routes() { + let input = format!( + "oauth-model-alias:\n codex:\n - name: user-opus\n alias: {opus}\n fork: true\n - name: app-sonnet\n alias: {sonnet}\n fork: true\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!( @@ -993,6 +1008,7 @@ fn claude_desktop_uses_selected_alias_directly_with_original_context() { opus_1m: false, sonnet_1m: true, haiku_1m: false, + ..ClaudeDesktopModelMappings::all("") }; let models = vec![ AgentModelOption { @@ -1060,6 +1076,7 @@ fn claude_desktop_role_mappings_can_use_different_available_models() { opus_1m: true, sonnet_1m: false, haiku_1m: true, + ..ClaudeDesktopModelMappings::all("") }), ) .unwrap() diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 8fbecaa..5e4d014 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -476,6 +476,7 @@ export const jaOverrides = { 'agents.error.noModels': '選択可能なモデルがないため、設定変更を適用できません', 'agents.error.selectionGone': '選択中のモデルは利用可能一覧にありません。更新後に選び直してください', 'agents.error.mappingSelectionGone': 'Claude Desktop のモデルマッピングに利用できないモデルがあります。モデルを更新して選び直してください', + 'agents.error.claudeCodeRuntimeSettingsInvalid': '最大コンテキストは 100000~1000000、自動圧縮率は 1~100 にしてください', 'agents.error.applyFirst': '選択したモデルはまだ適用されていません。先に「設定変更を適用」をクリックしてください', 'agents.status.detecting': '検出中', 'agents.status.unsupported': 'プラットフォーム非対応', @@ -570,7 +571,14 @@ export const jaOverrides = { 'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル', 'agents.claudeDesktopMapping.description': '役割ごとに上流モデルとコンテキスト機能を選択します。対応モデルでのみ 1M を有効にしてください。', 'agents.claudeCodeMapping.title': 'Claude Code モデル → 上流モデル', - 'agents.claudeCodeMapping.description': 'タスク役割ごとに上流モデルとコンテキスト機能を選択します。対応モデルでのみ 1M を有効にしてください。', + 'agents.claudeCodeMapping.description': 'タスク役割ごとに上流モデルを選び、共通のコンテキストと自動圧縮ポリシーを設定します。', + 'agents.claudeCodeRuntime.maxContextTokens': '最大コンテキスト', + 'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code の既定値は 200000 です。上流モデルの上限を超えないでください。', + 'agents.claudeCodeRuntime.context1mHint': 'このマッピングで [1m] モデルを使用し、最大コンテキストを 1000000 にします。3 つすべてを無効にすると 200000 に戻ります。', + 'agents.claudeCodeRuntime.autoCompactPct': '自動圧縮の開始率', + 'agents.claudeCodeRuntime.autoCompactPctHint': '既定値は 100% です。小さい値ほど早く圧縮します。', + 'agents.claudeCodeRuntime.disableAutoCompact': '自動圧縮を無効化', + 'agents.claudeCodeRuntime.disableAutoCompactHint': '有効時は /compact による手動圧縮のみ利用できます。', 'agents.claudeDesktopMapping.customMapping': 'モデルエイリアスを使用', 'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。', 'agents.claudeDesktopMapping.opus': 'Claude Opus 5', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e39efc9..33ec00d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -476,6 +476,7 @@ export const en: Record = { 'agents.error.noModels': 'No models are available, so configuration changes cannot be applied', 'agents.error.selectionGone': 'The selected model is no longer available. Refresh and select it again.', 'agents.error.mappingSelectionGone': 'A Claude Desktop mapping is no longer available. Refresh the models and select it again.', + 'agents.error.claudeCodeRuntimeSettingsInvalid': 'Maximum context must be 100000–1000000 and the auto-compact percentage must be 1–100.', 'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.', 'agents.status.detecting': 'Detecting', 'agents.status.unsupported': 'Unsupported Platform', @@ -565,7 +566,14 @@ export const en: Record = { '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.claudeCodeMapping.title': 'Claude Code Model → Upstream Model', - 'agents.claudeCodeMapping.description': 'Choose the upstream model and context capability for each task role. Turn on 1M only for supported models.', + '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', + 'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code defaults to 200000. Do not exceed the upstream model limit.', + 'agents.claudeCodeRuntime.context1mHint': 'Uses the [1m] model for this mapping and sets the maximum context to 1000000. It returns to 200000 after all three mappings are off.', + 'agents.claudeCodeRuntime.autoCompactPct': 'Auto-compact trigger', + 'agents.claudeCodeRuntime.autoCompactPctHint': 'Defaults to 100%. Lower values compact earlier.', + 'agents.claudeCodeRuntime.disableAutoCompact': 'Disable auto-compaction', + '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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 9ecde0d..08061f6 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -474,6 +474,7 @@ export const zhCN = { 'agents.error.noModels': '当前没有可选模型,无法应用配置修改', 'agents.error.selectionGone': '当前选择已不在可用模型列表中,请刷新模型后重新选择', 'agents.error.mappingSelectionGone': 'Claude Desktop 模型映射包含不可用模型,请刷新模型后重新选择', + 'agents.error.claudeCodeRuntimeSettingsInvalid': '最大窗口必须为 100000–1000000,触发压缩百分比必须为 1–100', 'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”', 'agents.status.detecting': '检测中', 'agents.status.unsupported': '平台不支持', @@ -563,7 +564,14 @@ export const zhCN = { 'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型', 'agents.claudeDesktopMapping.description': '分别选择上游模型和上下文能力;仅为确认支持的模型打开 1M。', 'agents.claudeCodeMapping.title': 'Claude Code 模型 → 上游模型', - 'agents.claudeCodeMapping.description': '分别为不同任务角色选择上游模型和上下文能力;仅为确认支持的模型打开 1M。', + 'agents.claudeCodeMapping.description': '分别为不同任务角色选择上游模型,并统一配置上下文窗口与自动压缩。', + 'agents.claudeCodeRuntime.maxContextTokens': '最大窗口', + 'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code 默认 200000;请勿超过上游模型实际支持的窗口。', + 'agents.claudeCodeRuntime.context1mHint': '为此映射使用 [1m] 模型并将最大窗口设为 1000000;三个映射均关闭后恢复为 200000。', + 'agents.claudeCodeRuntime.autoCompactPct': '触发压缩百分比', + 'agents.claudeCodeRuntime.autoCompactPctHint': '默认 100;调低后会更早自动压缩。', + 'agents.claudeCodeRuntime.disableAutoCompact': '禁止自动压缩', + 'agents.claudeCodeRuntime.disableAutoCompactHint': '开启后仅允许用户通过 /compact 手动压缩。', 'agents.claudeDesktopMapping.customMapping': '使用模型别名', 'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。', 'agents.claudeDesktopMapping.opus': 'Claude Opus 5', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 372faec..279b228 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -104,9 +104,14 @@ type ClaudeModelMappings = { opus1m: boolean; sonnet1m: boolean; haiku1m: boolean; + maxContextTokens: number; + autoCompactPct: number; + disableAutoCompact: boolean; }; const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED'; +const DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS = 200_000; +const DEFAULT_CLAUDE_AUTO_COMPACT_PCT = 100; const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({ opus: model, @@ -115,6 +120,9 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({ opus1m: false, sonnet1m: false, haiku1m: false, + maxContextTokens: DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS, + autoCompactPct: DEFAULT_CLAUDE_AUTO_COMPACT_PCT, + disableAutoCompact: false, }); const claudeMappingRoles = [ @@ -731,6 +739,9 @@ export function AgentsPage() { opus1m: Boolean(source.opus1m), sonnet1m: Boolean(source.sonnet1m), haiku1m: Boolean(source.haiku1m), + maxContextTokens: source.maxContextTokens ?? DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS, + autoCompactPct: source.autoCompactPct ?? DEFAULT_CLAUDE_AUTO_COMPACT_PCT, + disableAutoCompact: Boolean(source.disableAutoCompact), }; return sameAgentModelMappings(current, next) ? current : next; }); @@ -757,6 +768,12 @@ export function AgentsPage() { || claudeMappingRoles.every((role) => Boolean(findAgentModel(models, claudeModelMappingsDraft[role.key])), ); + const claudeCodeRuntimeSettingsReady = selected !== 'claude-code' || ( + claudeModelMappingsDraft.maxContextTokens >= 100_000 + && claudeModelMappingsDraft.maxContextTokens <= 1_000_000 + && claudeModelMappingsDraft.autoCompactPct >= 1 + && claudeModelMappingsDraft.autoCompactPct <= 100 + ); const claudeMappingDraftChanged = isClaudeModelMappingClient && activeStatus?.modificationState === 'applied' && !sameAgentModelMappings( @@ -781,7 +798,7 @@ export function AgentsPage() { && activeStatus.installed && !modelLoading && (isClaudeModelMappingClient - ? claudeMappingsReady + ? claudeMappingsReady && claudeCodeRuntimeSettingsReady : selectedModelOption), ); const modelHint = modelSelectionError @@ -836,7 +853,32 @@ export function AgentsPage() { enabled: boolean, ) => { claudeModelMappingsDirtyRef.current = true; - setClaudeModelMappingsDraft((current) => ({ ...current, [preference]: enabled })); + setClaudeModelMappingsDraft((current) => { + const next = { ...current, [preference]: enabled }; + if (selected === 'claude-code') { + const any1mEnabled = next.opus1m || next.sonnet1m || next.haiku1m; + next.maxContextTokens = any1mEnabled + ? 1_000_000 + : DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS; + } + return next; + }); + }; + + const changeClaudeCodeRuntimeSetting = ( + key: 'maxContextTokens' | 'autoCompactPct', + value: number, + ) => { + claudeModelMappingsDirtyRef.current = true; + setClaudeModelMappingsDraft((current) => ({ ...current, [key]: value })); + }; + + const changeClaudeCodeAutoCompactDisabled = (disabled: boolean) => { + claudeModelMappingsDirtyRef.current = true; + setClaudeModelMappingsDraft((current) => ({ + ...current, + disableAutoCompact: disabled, + })); }; const changeClaudeCustomMapping = (enabled: boolean) => { @@ -850,6 +892,9 @@ export function AgentsPage() { opus1m: current.opus1m, sonnet1m: current.sonnet1m, haiku1m: current.haiku1m, + maxContextTokens: current.maxContextTokens, + autoCompactPct: current.autoCompactPct, + disableAutoCompact: current.disableAutoCompact, }; if (!sameAgentModelMappings(current, next)) { claudeModelMappingsDirtyRef.current = true; @@ -888,6 +933,15 @@ export function AgentsPage() { resolved[role.key] = model.name; resolved[role.contextKey] = claudeModelMappingsDraft[role.contextKey]; } + if (selected === 'claude-code') { + if (!claudeCodeRuntimeSettingsReady) { + setModelSelectionError(t('agents.error.claudeCodeRuntimeSettingsInvalid')); + return null; + } + } + resolved.maxContextTokens = claudeModelMappingsDraft.maxContextTokens; + resolved.autoCompactPct = claudeModelMappingsDraft.autoCompactPct; + resolved.disableAutoCompact = claudeModelMappingsDraft.disableAutoCompact; return resolved; }; @@ -1291,6 +1345,72 @@ export function AgentsPage() { + {selected === 'claude-code' ? ( +
+
+ {t('agents.claudeCodeRuntime.maxContextTokens')} + changeClaudeCodeRuntimeSetting( + 'maxContextTokens', + Math.trunc(event.currentTarget.valueAsNumber || 0), + )} + disabled={busy || modelLoading} + aria-label={t('agents.claudeCodeRuntime.maxContextTokens')} + aria-describedby="claude-code-max-context-hint" + /> + + {t('agents.claudeCodeRuntime.maxContextTokensHint')} + +
+ + +
+ ) : null}
{claudeMappingRoles.map((role) => (
@@ -1298,7 +1418,9 @@ export function AgentsPage() { {t(role.labelKey)}