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
+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");
}
}