From ed988f7998272582f82e54c2b085b56c3cd7bf97 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 6 Oct 2026 15:04:45 +0800 Subject: [PATCH] Decouple Claude Code model management from CPA routing Add independent controls for the startup model and Subagent model. Preserve Claude Code model selection and inheritance when CPA management is disabled. Fixes #348 --- src-tauri/src/agents/commands.rs | 4 ++ src-tauri/src/agents/configuration.rs | 53 +++++++++++++++++++--- src-tauri/src/agents/discovery.rs | 12 +++++ src-tauri/src/agents/transactions.rs | 5 +- src-tauri/src/main.rs | 14 ++++++ src-tauri/src/tests/agent_configuration.rs | 51 +++++++++++++++++++++ src/i18n/ja.ts | 4 ++ src/i18n/locales/en.ts | 4 ++ src/i18n/locales/zh-CN.ts | 4 ++ src/pages/AgentsPage.tsx | 50 ++++++++++++++++++++ src/services/agentConfigurationDraft.ts | 6 ++- 11 files changed, 198 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index eb472ef..2eb0345 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -1040,6 +1040,8 @@ pub(crate) fn resolve_claude_desktop_model_mappings( max_context_tokens: requested.max_context_tokens, auto_compact_pct: requested.auto_compact_pct, disable_auto_compact: requested.disable_auto_compact, + manage_default_model: requested.manage_default_model, + manage_subagent_model: requested.manage_subagent_model, })) } @@ -1077,6 +1079,8 @@ pub(crate) fn resolve_claude_code_model_mappings( max_context_tokens, auto_compact_pct: requested.auto_compact_pct, disable_auto_compact: requested.disable_auto_compact, + manage_default_model: requested.manage_default_model, + manage_subagent_model: requested.manage_subagent_model, })) } diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index ff235f9..c17dcba 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -578,7 +578,6 @@ pub(crate) fn build_claude_agent_config( for (key, value) in [ ("ANTHROPIC_BASE_URL", base_url), ("ANTHROPIC_AUTH_TOKEN", api_key), - ("ANTHROPIC_MODEL", model_settings.sonnet.as_str()), ( "ANTHROPIC_DEFAULT_HAIKU_MODEL", model_settings.haiku.as_str(), @@ -598,8 +597,36 @@ pub(crate) fn build_claude_agent_config( serde_json::Value::String(value.to_string()), ); } - env.entry("CLAUDE_CODE_SUBAGENT_MODEL".to_string()) - .or_insert_with(|| serde_json::Value::String(subagent_model)); + env.insert( + "EASYCLIPROXY_MANAGE_CLAUDE_CODE_DEFAULT_MODEL".to_string(), + serde_json::Value::String(if mappings.manage_default_model { "1" } else { "0" }.to_string()), + ); + env.insert( + "EASYCLIPROXY_MANAGE_CLAUDE_CODE_SUBAGENT_MODEL".to_string(), + serde_json::Value::String(if mappings.manage_subagent_model { "1" } else { "0" }.to_string()), + ); + if mappings.manage_default_model { + env.insert( + "ANTHROPIC_MODEL".to_string(), + serde_json::Value::String(model_settings.sonnet.clone()), + ); + } else if env + .get("ANTHROPIC_MODEL") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| value == model_settings.opus || value == model_settings.sonnet || value == model_settings.haiku) + { + env.remove("ANTHROPIC_MODEL"); + } + if mappings.manage_subagent_model { + env.entry("CLAUDE_CODE_SUBAGENT_MODEL".to_string()) + .or_insert_with(|| serde_json::Value::String(subagent_model)); + } else if env + .get("CLAUDE_CODE_SUBAGENT_MODEL") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| value == model_settings.opus || value == model_settings.sonnet || value == model_settings.haiku) + { + env.remove("CLAUDE_CODE_SUBAGENT_MODEL"); + } env.insert( CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), serde_json::Value::String(max_context_tokens.to_string()), @@ -631,10 +658,18 @@ pub(crate) fn build_claude_agent_config( serde_json::Value::String(effort_level), ); } - root.insert( - "model".to_string(), - serde_json::Value::String(model_settings.sonnet), - ); + if mappings.manage_default_model { + root.insert( + "model".to_string(), + serde_json::Value::String(model_settings.sonnet), + ); + } else if root + .get("model") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| value == model_settings.opus || value == model_settings.sonnet || value == model_settings.haiku) + { + root.remove("model"); + } let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone())) .map_err(|error| format!("Failed to generate Claude Code configuration: {error}"))?; rendered.push('\n'); @@ -1144,6 +1179,8 @@ pub(crate) fn prepare_claude_code_managed_removal( "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL", + "EASYCLIPROXY_MANAGE_CLAUDE_CODE_DEFAULT_MODEL", + "EASYCLIPROXY_MANAGE_CLAUDE_CODE_SUBAGENT_MODEL", CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, CLAUDE_CODE_AUTO_MODE_SERVER_ENV, @@ -1774,6 +1811,8 @@ pub(crate) fn build_restored_claude_code_config( "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL", + "EASYCLIPROXY_MANAGE_CLAUDE_CODE_DEFAULT_MODEL", + "EASYCLIPROXY_MANAGE_CLAUDE_CODE_SUBAGENT_MODEL", CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, CLAUDE_CODE_AUTO_MODE_SERVER_ENV, diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 5c21d30..0b3e117 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -2963,6 +2963,16 @@ pub(crate) fn inspect_claude_code_model_mappings( max_context_tokens, auto_compact_pct, disable_auto_compact, + manage_default_model: env + .get("EASYCLIPROXY_MANAGE_CLAUDE_CODE_DEFAULT_MODEL") + .and_then(serde_json::Value::as_str) + .map(|value| value != "0" && !value.eq_ignore_ascii_case("false")) + .unwrap_or(true), + manage_subagent_model: env + .get("EASYCLIPROXY_MANAGE_CLAUDE_CODE_SUBAGENT_MODEL") + .and_then(serde_json::Value::as_str) + .map(|value| value != "0" && !value.eq_ignore_ascii_case("false")) + .unwrap_or(true), })) } @@ -3089,6 +3099,8 @@ pub(crate) fn claude_code_model_settings( max_context_tokens: mappings.max_context_tokens, auto_compact_pct: mappings.auto_compact_pct, disable_auto_compact: mappings.disable_auto_compact, + manage_default_model: mappings.manage_default_model, + manage_subagent_model: mappings.manage_subagent_model, } } diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index c85b2c9..a3d18a5 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -844,7 +844,10 @@ fn validate_unmanaged_preserved( env.retain(|key, _| { !matches!( key.as_str(), - "CLAUDE_CODE_SUBAGENT_MODEL" | "CLAUDE_CODE_EFFORT_LEVEL" + "CLAUDE_CODE_SUBAGENT_MODEL" + | "CLAUDE_CODE_EFFORT_LEVEL" + | "EASYCLIPROXY_MANAGE_CLAUDE_CODE_DEFAULT_MODEL" + | "EASYCLIPROXY_MANAGE_CLAUDE_CODE_SUBAGENT_MODEL" ) && !key.starts_with("ANTHROPIC_DEFAULT_") && !key.starts_with("ANTHROPIC_MODEL_") && !key.starts_with("ANTHROPIC_CUSTOM_MODEL_OPTION") diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 63fae1d..2546a9e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1150,6 +1150,14 @@ struct ClaudeDesktopModelMappings { auto_compact_pct: u8, #[serde(default)] disable_auto_compact: bool, + /// Keep Claude Code's session/default model selection under CPA control. + /// Defaults to true for backwards compatibility with existing profiles. + #[serde(default = "default_true")] + manage_default_model: bool, + /// Set CLAUDE_CODE_SUBAGENT_MODEL when enabled; otherwise allow inheritance. + /// Defaults to true for backwards compatibility with existing profiles. + #[serde(default = "default_true")] + manage_subagent_model: bool, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -1195,6 +1203,10 @@ fn default_claude_auto_compact_pct() -> u8 { DEFAULT_CLAUDE_AUTO_COMPACT_PCT } +fn default_true() -> bool { + true +} + impl ClaudeDesktopModelMappings { fn all(model: &str) -> Self { Self { @@ -1208,6 +1220,8 @@ impl ClaudeDesktopModelMappings { max_context_tokens: default_claude_code_max_context_tokens(), auto_compact_pct: default_claude_auto_compact_pct(), disable_auto_compact: false, + manage_default_model: true, + manage_subagent_model: true, } } } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index df42a98..770829c 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -154,6 +154,8 @@ fn claude_code_role_mappings_drive_settings() { max_context_tokens: 272_000, auto_compact_pct: 80, disable_auto_compact: false, + manage_default_model: true, + manage_subagent_model: true, }; let models = vec![ AgentModelOption { @@ -210,6 +212,53 @@ fn claude_code_role_mappings_drive_settings() { ); } +#[test] +fn claude_code_can_leave_session_model_and_subagents_to_claude_code() { + let mappings = ClaudeDesktopModelMappings { + manage_default_model: false, + manage_subagent_model: false, + ..ClaudeDesktopModelMappings::all("gpt-sonnet") + }; + let rendered = build_claude_agent_config( + Some(r#"{"model":"user-selected","env":{"ANTHROPIC_MODEL":"user-selected","CLAUDE_CODE_SUBAGENT_MODEL":"user-subagent"}}"#), + "http://127.0.0.1:8317", + "test-key", + "gpt-sonnet", + &test_agent_models(&["gpt-sonnet"]), + Some(&mappings), + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(value["model"], "user-selected"); + assert_eq!(value["env"]["ANTHROPIC_MODEL"], "user-selected"); + assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "user-subagent"); + assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], "gpt-sonnet"); + assert_eq!(value["env"]["EASYCLIPROXY_MANAGE_CLAUDE_CODE_DEFAULT_MODEL"], "0"); + assert_eq!(value["env"]["EASYCLIPROXY_MANAGE_CLAUDE_CODE_SUBAGENT_MODEL"], "0"); +} + +#[test] +fn claude_code_unmanaged_mode_removes_previous_cpa_overrides() { + let mappings = ClaudeDesktopModelMappings { + manage_default_model: false, + manage_subagent_model: false, + ..ClaudeDesktopModelMappings::all("gpt-sonnet") + }; + let rendered = build_claude_agent_config( + Some(r#"{"model":"gpt-sonnet","env":{"ANTHROPIC_MODEL":"gpt-sonnet","CLAUDE_CODE_SUBAGENT_MODEL":"gpt-sonnet"}}"#), + "http://127.0.0.1:8317", + "test-key", + "gpt-sonnet", + &test_agent_models(&["gpt-sonnet"]), + Some(&mappings), + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert!(value.get("model").is_none()); + assert!(value["env"].get("ANTHROPIC_MODEL").is_none()); + assert!(value["env"].get("CLAUDE_CODE_SUBAGENT_MODEL").is_none()); +} + #[test] fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { let mappings = ClaudeDesktopModelMappings { @@ -223,6 +272,8 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { max_context_tokens: 1_000_000, auto_compact_pct: 75, disable_auto_compact: true, + manage_default_model: true, + manage_subagent_model: true, }; let models = vec![ AgentModelOption { diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 5bd08bc..27413e8 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1460,6 +1460,10 @@ export const jaOverrides = { 'agents.claudeCodeRuntime.autoCompactPctHint': '既定値は 90% です。小さい値ほど早く圧縮します。', 'agents.claudeCodeRuntime.disableAutoCompact': '自動圧縮を無効化', 'agents.claudeCodeRuntime.disableAutoCompactHint': '有効時は /compact による手動圧縮のみ利用できます。', + 'agents.claudeCodeRuntime.manageDefaultModel': '起動モデルを CPA が管理', + 'agents.claudeCodeRuntime.manageDefaultModelHint': '無効にすると ANTHROPIC_MODEL と model を上書きせず、/model で既定モデルを保存できます。', + 'agents.claudeCodeRuntime.manageSubagentModel': 'Subagent モデルを CPA が固定', + 'agents.claudeCodeRuntime.manageSubagentModelHint': '無効にすると CLAUDE_CODE_SUBAGENT_MODEL を設定せず、未指定の Subagent はメインモデルを継承します。', 'agents.claudeDesktopMapping.customMapping': 'モデルエイリアスを使用', 'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。', 'agents.claudeDesktopMapping.opus': 'Opus', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7f58bc1..4e1f1fd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1426,6 +1426,10 @@ export const en: Record = { 'agents.claudeCodeRuntime.autoCompactPctHint': 'Defaults to 90%. Lower values compact earlier.', 'agents.claudeCodeRuntime.disableAutoCompact': 'Disable auto-compaction', 'agents.claudeCodeRuntime.disableAutoCompactHint': 'Only manual /compact remains available when enabled.', + 'agents.claudeCodeRuntime.manageDefaultModel': 'Let CPA manage the startup model', + 'agents.claudeCodeRuntime.manageDefaultModelHint': 'When off, CPA leaves ANTHROPIC_MODEL and the root model untouched so /model can save the default.', + 'agents.claudeCodeRuntime.manageSubagentModel': 'Let CPA pin the Subagent model', + 'agents.claudeCodeRuntime.manageSubagentModelHint': 'When off, CPA leaves CLAUDE_CODE_SUBAGENT_MODEL unset so unspecified Subagents inherit the main model.', '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': 'Opus', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 7a600e8..2a95248 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1425,6 +1425,10 @@ export const zhCN = { 'agents.claudeCodeRuntime.autoCompactPctHint': '默认 90%;调低后会更早自动压缩。', 'agents.claudeCodeRuntime.disableAutoCompact': '禁止自动压缩', 'agents.claudeCodeRuntime.disableAutoCompactHint': '开启后仅允许用户通过 /compact 手动压缩。', + 'agents.claudeCodeRuntime.manageDefaultModel': '由 CPA 管理启动模型', + 'agents.claudeCodeRuntime.manageDefaultModelHint': '关闭后不再写入 ANTHROPIC_MODEL 或根级 model,Claude Code 可通过 /model 保存默认模型。', + 'agents.claudeCodeRuntime.manageSubagentModel': '由 CPA 固定 Subagent 模型', + 'agents.claudeCodeRuntime.manageSubagentModelHint': '关闭后不再写入 CLAUDE_CODE_SUBAGENT_MODEL,未指定模型的 Subagent 继承主会话模型。', 'agents.claudeDesktopMapping.customMapping': '使用模型别名', 'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。', 'agents.claudeDesktopMapping.opus': 'Opus', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 96fe48b..8f98222 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -156,6 +156,8 @@ type ClaudeModelMappings = { maxContextTokens: number; autoCompactPct: number; disableAutoCompact: boolean; + manageDefaultModel: boolean; + manageSubagentModel: boolean; }; type AgentFormValues = { @@ -186,6 +188,8 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({ maxContextTokens: DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS, autoCompactPct: DEFAULT_CLAUDE_AUTO_COMPACT_PCT, disableAutoCompact: false, + manageDefaultModel: true, + manageSubagentModel: true, }); const createClaudeModelMappingsByClient = (): Record< @@ -888,6 +892,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP maxContextTokens: source.maxContextTokens ?? DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS, autoCompactPct: source.autoCompactPct ?? DEFAULT_CLAUDE_AUTO_COMPACT_PCT, disableAutoCompact: Boolean(source.disableAutoCompact), + manageDefaultModel: source.manageDefaultModel !== false, + manageSubagentModel: source.manageSubagentModel !== false, }; return sameAgentModelMappings(currentClientDraft, next) ? current @@ -1156,6 +1162,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP resolved.maxContextTokens = claudeModelMappingsDraft.maxContextTokens; resolved.autoCompactPct = claudeModelMappingsDraft.autoCompactPct; resolved.disableAutoCompact = claudeModelMappingsDraft.disableAutoCompact; + resolved.manageDefaultModel = claudeModelMappingsDraft.manageDefaultModel; + resolved.manageSubagentModel = claudeModelMappingsDraft.manageSubagentModel; return resolved; }; @@ -2095,6 +2103,48 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP + + ) : null}
diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index 9aa1d63..3e1a685 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -28,6 +28,8 @@ export type AgentModelMappings = { maxContextTokens?: number; autoCompactPct?: number; disableAutoCompact?: boolean; + manageDefaultModel?: boolean; + manageSubagentModel?: boolean; }; type ResolveAgentConfigurationActionOptions = { @@ -67,7 +69,9 @@ export const sameAgentModelMappings = ( && Boolean(left.haiku1m) === Boolean(right.haiku1m) && (left.maxContextTokens ?? 200_000) === (right.maxContextTokens ?? 200_000) && (left.autoCompactPct ?? 90) === (right.autoCompactPct ?? 90) - && Boolean(left.disableAutoCompact) === Boolean(right.disableAutoCompact); + && Boolean(left.disableAutoCompact) === Boolean(right.disableAutoCompact) + && (left.manageDefaultModel ?? true) === (right.manageDefaultModel ?? true) + && (left.manageSubagentModel ?? true) === (right.manageSubagentModel ?? true); export const resolveAgentModelMappingsDraftSource = ( current: T,