fix(proxy): expose real provider model names in Claude Code menu under takeover

When proxy takeover is active, write per-role *_MODEL aliases for routing
and *_MODEL_NAME with the upstream provider's real model name so the
Claude Code model menu reflects the active provider instead of stale
display names from a previous switch. Preserves the [1M] capability marker
for Sonnet/Opus, and strips it from implicit display names.
This commit is contained in:
Jason
2026-05-14 12:16:00 +08:00
parent 402570ce31
commit f93b935d5f
2 changed files with 175 additions and 36 deletions
+4 -1
View File
@@ -1798,12 +1798,15 @@ impl ProviderService {
// Auth
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
// Models (4 fields + 1 legacy)
// Models and Claude Code model-menu display names
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
// Endpoint
"ANTHROPIC_BASE_URL",
];
+171 -35
View File
@@ -12,7 +12,7 @@ use crate::proxy::types::*;
use crate::services::provider::{
build_effective_settings_with_common_config, write_live_with_common_config,
};
use serde_json::{json, Value};
use serde_json::{json, Map, Value};
use std::str::FromStr;
use std::sync::Arc;
use tauri::Emitter;
@@ -23,18 +23,28 @@ const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
/// 代理接管模式下需要从 Claude Live 配置中移除的"模型覆盖"字段。
///
/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [
/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名
/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,
/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
// Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
"ANTHROPIC_SMALL_FAST_MODEL",
];
const CLAUDE_TAKEOVER_HAIKU_MODEL: &str = "claude-haiku-4-5";
const CLAUDE_TAKEOVER_SONNET_MODEL: &str = "claude-sonnet-4-6";
const CLAUDE_TAKEOVER_OPUS_MODEL: &str = "claude-opus-4-7";
// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。
const CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = "[1M]";
#[derive(Clone)]
pub struct ProxyService {
db: Arc<Database>,
@@ -59,32 +69,10 @@ impl ProxyService {
}
}
/// 清理接管模式下 Claude Live 配置中的模型覆盖字段。
///
/// 这可以避免"接管开启后切换供应商仍使用旧模型"的问题。
/// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。
pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {
let mut config = self.read_claude_live()?;
let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else {
return Ok(());
};
let mut changed = false;
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
if env.remove(key).is_some() {
changed = true;
}
}
if changed {
self.write_claude_live(&config)?;
}
Ok(())
}
fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {
// 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。
let takeover_model_fields = Self::build_claude_takeover_model_fields(config);
if !config.is_object() {
*config = json!({});
}
@@ -106,6 +94,10 @@ impl ProxyService {
env.remove(key);
}
for (key, value) in takeover_model_fields {
env.insert(key.to_string(), Value::String(value));
}
let token_keys = [
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
@@ -129,6 +121,101 @@ impl ProxyService {
}
}
fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {
let Some(env) = config.get("env").and_then(Value::as_object) else {
return Vec::new();
};
let default_model = Self::claude_env_string(env, "ANTHROPIC_MODEL");
let small_fast_model = Self::claude_env_string(env, "ANTHROPIC_SMALL_FAST_MODEL");
let haiku_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL")
.or(small_fast_model)
.or(default_model);
let sonnet_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_SONNET_MODEL")
.or(default_model)
.or(small_fast_model);
let opus_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_OPUS_MODEL")
.or(default_model)
.or(small_fast_model);
let mut fields = Vec::with_capacity(6);
Self::push_claude_takeover_role_fields(
&mut fields,
env,
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
CLAUDE_TAKEOVER_HAIKU_MODEL,
false,
haiku_model,
);
Self::push_claude_takeover_role_fields(
&mut fields,
env,
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
CLAUDE_TAKEOVER_SONNET_MODEL,
true,
sonnet_model,
);
Self::push_claude_takeover_role_fields(
&mut fields,
env,
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
CLAUDE_TAKEOVER_OPUS_MODEL,
true,
opus_model,
);
fields
}
fn push_claude_takeover_role_fields(
fields: &mut Vec<(&'static str, String)>,
env: &Map<String, Value>,
model_key: &'static str,
name_key: &'static str,
takeover_model: &'static str,
supports_one_m: bool,
upstream_model: Option<&str>,
) {
let Some(upstream_model) = upstream_model else {
return;
};
let mut client_model = takeover_model.to_string();
if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {
client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);
}
fields.push((model_key, client_model));
let display_name = Self::claude_env_string(env, name_key)
.map(str::to_string)
.unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));
if !display_name.is_empty() {
fields.push((name_key, display_name));
}
}
fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
env.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn has_claude_one_m_marker(model: &str) -> bool {
model
.trim_end()
.to_ascii_lowercase()
.ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)
}
fn strip_claude_one_m_marker(model: &str) -> String {
crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)
.trim()
.to_string()
}
pub async fn sync_claude_live_from_provider_while_proxy_active(
&self,
provider: &Provider,
@@ -1566,9 +1653,6 @@ impl ProxyService {
if matches!(app_type_enum, AppType::Claude) {
self.sync_claude_live_from_provider_while_proxy_active(&provider)
.await?;
if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}");
}
}
}
@@ -2247,7 +2331,12 @@ model = "gpt-5.1-codex"
"env": {
"ANTHROPIC_API_KEY": "b-key",
"ANTHROPIC_BASE_URL": "https://api.b.example",
"ANTHROPIC_MODEL": "claude-new"
"ANTHROPIC_MODEL": "claude-new",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash",
"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]"
},
"permissions": { "allow": ["Read"] }
}),
@@ -2273,7 +2362,8 @@ model = "gpt-5.1-codex"
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
"ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER,
"ANTHROPIC_MODEL": "stale-model"
"ANTHROPIC_MODEL": "stale-model",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet"
},
"permissions": { "allow": ["Bash"] }
}))
@@ -2308,7 +2398,53 @@ model = "gpt-5.1-codex"
live.get("env")
.and_then(|env| env.get("ANTHROPIC_MODEL"))
.is_none(),
"Claude model override fields should be removed in takeover mode"
"fallback model override should be removed in takeover mode"
);
let live_env = live
.get("env")
.and_then(|env| env.as_object())
.expect("live env");
assert_eq!(
live_env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str()),
Some("claude-haiku-4-5"),
"takeover mode should expose a stable Haiku role model"
);
assert_eq!(
live_env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME")
.and_then(|v| v.as_str()),
Some("DeepSeek V4 Flash"),
"model menu should show the current provider Haiku display name"
);
assert_eq!(
live_env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str()),
Some("claude-sonnet-4-6[1M]"),
"Sonnet role should carry the local 1M declaration for Claude Code"
);
assert_eq!(
live_env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL_NAME")
.and_then(|v| v.as_str()),
Some("DeepSeek V4 Pro"),
"stale model display names should be replaced during hot switch"
);
assert_eq!(
live_env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str()),
Some("claude-opus-4-7[1M]"),
"Opus role should preserve the current provider 1M capability marker"
);
assert_eq!(
live_env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL_NAME")
.and_then(|v| v.as_str()),
Some("deepseek-v4-ultra"),
"implicit display names should strip the local 1M marker"
);
let backup = db