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
This commit is contained in:
lzt404 committed 2026-10-06 15:36:29 +08:00
1 parent 35190a2ee3
commit ed988f7998
11 files changed
+198 -9

No files matched your search

+4
View File
@@ -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,
}))
}
+46 -7
View File
@@ -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,
+12
View File
@@ -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,
}
}
+4 -1
View File
@@ -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")
+14
View File
@@ -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,
}
}
}
@@ -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 {
+4
View File
@@ -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',
+4
View File
@@ -1426,6 +1426,10 @@ export const en: Record<MessageKey, string> = {
'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',
+4
View File
@@ -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',
+50
View File
@@ -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
<span className="switch-track" />
</span>
</label>
<label
className="agent-claude-code-disable-compact"
title={t('agents.claudeCodeRuntime.manageDefaultModelHint')}
>
<span>
<strong>{t('agents.claudeCodeRuntime.manageDefaultModel')}</strong>
<small>{t('agents.claudeCodeRuntime.manageDefaultModelHint')}</small>
</span>
<span className="switch-control">
<input
type="checkbox"
checked={claudeModelMappingsDraft.manageDefaultModel}
onChange={(event) => editClaudeModelMappings((current) => ({
...current,
manageDefaultModel: event.currentTarget.checked,
}))}
disabled={busy || loading || modelLoading}
/>
<span className="switch-track" />
</span>
</label>
<label
className="agent-claude-code-disable-compact"
title={t('agents.claudeCodeRuntime.manageSubagentModelHint')}
>
<span>
<strong>{t('agents.claudeCodeRuntime.manageSubagentModel')}</strong>
<small>{t('agents.claudeCodeRuntime.manageSubagentModelHint')}</small>
</span>
<span className="switch-control">
<input
type="checkbox"
checked={claudeModelMappingsDraft.manageSubagentModel}
onChange={(event) => editClaudeModelMappings((current) => ({
...current,
manageSubagentModel: event.currentTarget.checked,
}))}
disabled={busy || loading || modelLoading}
/>
<span className="switch-track" />
</span>
</label>
</div>
) : null}
<div className="agent-claude-desktop-mapping-grid">
+5 -1
View File
@@ -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 = <T>(
current: T,