feat(claude-code): role-based model mapping with display names and 1M flag

- Replace the four flat env inputs with a Sonnet/Opus/Haiku role table.
  Each row exposes ANTHROPIC_DEFAULT_*_MODEL plus a new display name
  field ANTHROPIC_DEFAULT_*_MODEL_NAME, and Sonnet/Opus gain a
  "Declare 1M" checkbox that toggles the [1M] suffix.
- Strip the [1M] context-capability marker before forwarding non-Copilot
  requests upstream. Copilot keeps its existing [1m]->-1m normalization.
- Claude Desktop import now consumes ANTHROPIC_DEFAULT_*_MODEL_NAME as
  label_override, closing the Claude Code -> Claude Desktop displayName
  pipeline; add_route's merge logic is shared between hashmap branches.
- Unify the [1M] marker as ONE_M_CONTEXT_MARKER across
  claude_desktop_config and proxy::model_mapper; rename the strip
  helper to strip_one_m_suffix_for_upstream.
- Collapse useModelState's seven duplicated useState initializers and
  the useEffect parse block into a single parseModelsFromConfig call.
- Add tests/hooks/useModelState.test.tsx and a Claude Desktop import
  test covering Kimi K2 -> label_override. i18n (en/ja/zh) updated.
This commit is contained in:
Jason
2026-05-13 17:06:05 +08:00
parent 84bac6dce6
commit 4b57f7e113
11 changed files with 550 additions and 169 deletions
+4 -3
View File
@@ -26,8 +26,9 @@ const DEFAULT_CREATED_AT: &str = "2024-01-01T00:00:00Z";
pub const CLAUDE_ROUTE_PREFIX: &str = "claude-";
/// 替代前缀(与前端 `ANTHROPIC_CLAUDE_ROUTE_PREFIX` 一致)。
pub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = "anthropic/claude-";
/// cc-switch 历史约定的 1M 上下文标记(ASCII 小写形式,匹配用 `eq_ignore_ascii_case`)。
pub const LEGACY_ONE_M_MARKER: &str = "[1m]";
/// Claude Code env 中通过 `[1M]` 后缀声明 1M 上下文能力(匹配用 `eq_ignore_ascii_case`)。
/// Claude Desktop schema 不接受此后缀,import 边界翻译为 `supports1m` 字段。
pub const ONE_M_CONTEXT_MARKER: &str = "[1m]";
const NON_ANTHROPIC_ROUTE_MARKERS: &[&str] = &[
"ark-code",
@@ -267,7 +268,7 @@ pub fn is_claude_safe_model_id(model: &str) -> bool {
|| (normalized.starts_with("opus-") && normalized.len() > "opus-".len())
|| (normalized.starts_with("haiku-") && normalized.len() > "haiku-".len());
has_allowed_shape
&& !normalized.contains(LEGACY_ONE_M_MARKER)
&& !normalized.contains(ONE_M_CONTEXT_MARKER)
&& !NON_ANTHROPIC_ROUTE_MARKERS
.iter()
.any(|marker| normalized.contains(marker))
+45 -15
View File
@@ -275,7 +275,7 @@ pub(crate) fn suggested_claude_desktop_routes(
// Claude 端 env 值可能带 [1M] 后缀;Claude Desktop schema 不接受后缀,
// 改用 supports1m 字段表达 1M 能力。在 import 边界做单向翻译。
let marker = crate::claude_desktop_config::LEGACY_ONE_M_MARKER.as_bytes();
let marker = crate::claude_desktop_config::ONE_M_CONTEXT_MARKER.as_bytes();
let raw_bytes = raw_model.as_bytes();
let has_1m_marker = raw_bytes.len() >= marker.len()
&& raw_bytes[raw_bytes.len() - marker.len()..].eq_ignore_ascii_case(marker);
@@ -288,31 +288,44 @@ pub(crate) fn suggested_claude_desktop_routes(
return;
}
let effective_supports_1m = supports_1m_default || has_1m_marker;
let label_override =
let explicit_label_override = env
.get(&format!("{env_key}_NAME"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let label_override = explicit_label_override.clone().or_else(|| {
(!crate::claude_desktop_config::is_claude_safe_model_id(stripped_model))
.then(|| stripped_model.to_string());
.then(|| stripped_model.to_string())
});
// 何时覆盖既有 label_override:原本为空 / 这次来的是 explicit _NAME /
// 既有值只是 stripped_model 派生的占位(被 explicit 或更具体的值挤掉)。
let should_overwrite = |existing: Option<&str>| {
existing.is_none()
|| explicit_label_override.is_some()
|| existing == Some(stripped_model)
};
let merge_into = |existing: &mut crate::provider::ClaudeDesktopModelRoute| {
let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m;
existing.supports_1m = Some(merged);
if should_overwrite(existing.label_override.as_deref()) {
existing.label_override = label_override.clone();
}
};
if let Some(existing) = routes
.values_mut()
.find(|existing| existing.model == stripped_model)
{
let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m;
existing.supports_1m = Some(merged);
if existing.label_override.is_none() {
existing.label_override = label_override;
}
merge_into(existing);
return;
}
routes
.entry(route_key.to_string())
.and_modify(|existing| {
let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m;
existing.supports_1m = Some(merged);
if existing.label_override.is_none() {
existing.label_override = label_override.clone();
}
})
.and_modify(merge_into)
.or_insert_with(|| crate::provider::ClaudeDesktopModelRoute {
model: stripped_model.to_string(),
label_override,
@@ -791,6 +804,23 @@ mod import_claude_desktop_tests {
assert_eq!(r.supports_1m, Some(true));
}
#[test]
fn route_uses_claude_code_model_name_as_label_override() {
let p = make_provider(
json!({
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Kimi K2",
}),
None,
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes
.get("claude-sonnet-4-6")
.expect("sonnet route present");
assert_eq!(r.model, "kimi-k2");
assert_eq!(r.label_override.as_deref(), Some("Kimi K2"));
}
#[test]
fn route_1m_suffix_overrides_provider_type_default() {
// github_copilot 默认 supports_1m_default = false,但 [1M] 后缀应强制 true
+11 -9
View File
@@ -803,6 +803,14 @@ impl RequestForwarder {
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
let is_copilot = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
// 应用模型映射(独立于格式转换)
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
@@ -818,20 +826,14 @@ impl RequestForwarder {
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mut mapped_body = normalize_thinking_type(mapped_body);
// 确定有效端点
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
let is_copilot = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
if is_copilot {
mapped_body =
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
.await;
} else {
mapped_body =
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
}
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
+58
View File
@@ -2,6 +2,7 @@
//!
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
use crate::claude_desktop_config::ONE_M_CONTEXT_MARKER;
use crate::provider::Provider;
use serde_json::Value;
@@ -112,6 +113,33 @@ pub fn apply_model_mapping(
(body, original_model, None)
}
/// Claude Code 通过 `[1M]` 后缀声明 100 万上下文能力;上游 API
/// 通常不接受这个本地能力标记,转发前需要剥离。
pub fn strip_one_m_suffix_for_upstream(model: &str) -> &str {
let trimmed = model.trim_end();
let marker = ONE_M_CONTEXT_MARKER.as_bytes();
let bytes = trimmed.as_bytes();
if bytes.len() >= marker.len()
&& bytes[bytes.len() - marker.len()..].eq_ignore_ascii_case(marker)
{
return trimmed[..trimmed.len() - marker.len()].trim_end();
}
model
}
pub fn strip_one_m_suffix_for_upstream_from_body(mut body: Value) -> Value {
let Some(model) = body.get("model").and_then(Value::as_str) else {
return body;
};
let stripped = strip_one_m_suffix_for_upstream(model);
if stripped != model {
log::debug!("[ModelMapper] 去除本地 1M 标记: {model} → {stripped}");
body["model"] = serde_json::json!(stripped);
}
body
}
#[cfg(test)]
mod tests {
use super::*;
@@ -251,4 +279,34 @@ mod tests {
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn strips_one_m_suffix_before_upstream() {
let body = json!({"model": "deepseek-v4-pro[1M]"});
let result = strip_one_m_suffix_for_upstream_from_body(body);
assert_eq!(result["model"], "deepseek-v4-pro");
}
#[test]
fn strips_one_m_suffix_after_mapping() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro [1M]"
}
});
let body = json!({"model": "claude-sonnet-4-6"});
let (mapped, _, _) = apply_model_mapping(body, &provider);
let result = strip_one_m_suffix_for_upstream_from_body(mapped);
assert_eq!(result["model"], "deepseek-v4-pro");
}
#[test]
fn keeps_model_without_one_m_suffix() {
let body = json!({"model": "deepseek-v4-pro"});
let result = strip_one_m_suffix_for_upstream_from_body(body);
assert_eq!(result["model"], "deepseek-v4-pro");
}
}