mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 优化器:分类 + 请求体优化(在格式转换之前执行) ---
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user