feat: add Claude subagent model config (#4830)

* feat: add Claude subagent takeover config

* feat: add Claude subagent model field

* i18n: add Claude subagent model labels

* fix(proxy): preserve configured subagent model mapping

* fix(providers): exclude subagent model from Claude common config

* style: format rust code
This commit is contained in:
秋澪Akimio
2026-07-07 10:28:36 +08:00
committed by GitHub
parent e606adfae7
commit b3e5e32c89
12 changed files with 257 additions and 33 deletions
+50
View File
@@ -12,6 +12,7 @@ pub struct ModelMapping {
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub fable_model: Option<String>,
pub subagent_model: Option<String>,
pub default_model: Option<String>,
}
@@ -41,6 +42,11 @@ impl ModelMapping {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
subagent_model: env
.and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
default_model: env
.and_then(|e| e.get("ANTHROPIC_MODEL"))
.and_then(|v| v.as_str())
@@ -55,6 +61,7 @@ impl ModelMapping {
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.fable_model.is_some()
|| self.subagent_model.is_some()
|| self.default_model.is_some()
}
@@ -89,6 +96,13 @@ impl ModelMapping {
}
}
if let Some(ref m) = self.subagent_model {
if strip_one_m_suffix_for_upstream(original_model) == strip_one_m_suffix_for_upstream(m)
{
return original_model.to_string();
}
}
// 2. 默认模型
if let Some(ref m) = self.default_model {
return m.clone();
@@ -327,6 +341,42 @@ mod tests {
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_subagent_model_preserved_before_default_fallback() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini"
}
});
let body = json!({"model": "gpt-5.4-mini"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "gpt-5.4-mini");
assert_eq!(original, Some("gpt-5.4-mini".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_subagent_model_preserved_with_one_m_suffix_before_default_fallback() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini"
}
});
let body = json!({"model": "gpt-5.4-mini[1M]"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "gpt-5.4-mini[1M]");
assert_eq!(original, Some("gpt-5.4-mini[1M]".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_no_mapping_configured() {
let provider = create_provider_without_mapping();
+5
View File
@@ -672,6 +672,7 @@ mod tests {
"AWS_BEARER_TOKEN_BEDROCK": "bedrock-tok",
"ANTHROPIC_BASE_URL": "https://example.com",
"ANTHROPIC_MODEL": "claude-x",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini",
// 可共享、非机密配置(复数 _TOKENS 不应被误剥)
"ENABLE_TOOL_SEARCH": "true",
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192"
@@ -714,6 +715,9 @@ mod tests {
// 端点/模型(provider-specific 非机密)也应剥掉
assert!(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")).is_none());
assert!(env.and_then(|e| e.get("ANTHROPIC_MODEL")).is_none());
assert!(env
.and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL"))
.is_none());
// 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥)
assert_eq!(
@@ -2635,6 +2639,7 @@ impl ProviderService {
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
"CLAUDE_CODE_SUBAGENT_MODEL",
"ANTHROPIC_BASE_URL",
];
+67 -8
View File
@@ -26,7 +26,7 @@ const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,
/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,
/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 10] = [
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
@@ -35,8 +35,8 @@ const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
// Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
"ANTHROPIC_SMALL_FAST_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL", // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
"CLAUDE_CODE_SUBAGENT_MODEL",
];
const CLAUDE_TAKEOVER_HAIKU_MODEL: &str = "claude-haiku-4-5";
@@ -222,7 +222,9 @@ impl ProxyService {
.or(default_model)
.or(small_fast_model);
let mut fields = Vec::with_capacity(6);
let subagent_model = Self::claude_env_string(env, "CLAUDE_CODE_SUBAGENT_MODEL");
let mut fields = Vec::with_capacity(7);
Self::push_claude_takeover_role_fields(
&mut fields,
env,
@@ -250,6 +252,9 @@ impl ProxyService {
true,
opus_model,
);
if let Some(subagent_model) = subagent_model {
fields.push(("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.to_string()));
}
fields
}
@@ -2857,7 +2862,8 @@ mod tests {
"ANTHROPIC_MODEL": "claude-sonnet-4.6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4.5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4.6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-4.6"
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-4.6",
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4.6[1M]"
}
}),
None,
@@ -2877,7 +2883,8 @@ mod tests {
"ANTHROPIC_DEFAULT_SONNET_MODEL": "stale-sonnet",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "stale-opus",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus"
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus",
"CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent"
}
});
ProxyService::apply_claude_takeover_fields_for_provider(
@@ -2917,10 +2924,53 @@ mod tests {
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
Some("claude-sonnet-4.6"),
);
assert_env_str(
env,
"CLAUDE_CODE_SUBAGENT_MODEL",
Some("claude-sonnet-4.6[1M]"),
);
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
}
#[test]
fn managed_account_claude_takeover_removes_stale_subagent_model_when_provider_omits_it() {
let mut provider = Provider::with_id(
"codex".to_string(),
"Codex".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "provider-sonnet"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
..Default::default()
});
let mut live_config = json!({
"env": {
"ANTHROPIC_BASE_URL": "https://stale.example.com",
"ANTHROPIC_API_KEY": "stale-key",
"CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent"
}
});
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
assert_env_str(env, "CLAUDE_CODE_SUBAGENT_MODEL", None);
}
#[test]
fn managed_account_claude_takeover_sources_codex_models_from_provider() {
let mut provider = Provider::with_id(
@@ -4564,7 +4614,8 @@ model = "gpt-5.1-codex"
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "DeepSeek V4 Flash",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1M]",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "DeepSeek V4 Pro",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]"
"ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]",
"CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-pro[1M]"
},
"permissions": { "allow": ["Read"] }
}),
@@ -4591,7 +4642,8 @@ model = "gpt-5.1-codex"
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
"ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER,
"ANTHROPIC_MODEL": "stale-model",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet"
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet",
"CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent"
},
"permissions": { "allow": ["Bash"] }
}))
@@ -4674,6 +4726,13 @@ model = "gpt-5.1-codex"
Some("deepseek-v4-ultra"),
"implicit display names should strip the local 1M marker"
);
assert_eq!(
live_env
.get("CLAUDE_CODE_SUBAGENT_MODEL")
.and_then(|v| v.as_str()),
Some("deepseek-v4-pro[1M]"),
"subagent model should follow the target provider during hot switch"
);
let backup = db
.get_live_backup("claude")