mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +08:00
4b57f7e113
- 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.
313 lines
10 KiB
Rust
313 lines
10 KiB
Rust
//! 模型映射模块
|
|
//!
|
|
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
|
|
|
|
use crate::claude_desktop_config::ONE_M_CONTEXT_MARKER;
|
|
use crate::provider::Provider;
|
|
use serde_json::Value;
|
|
|
|
/// 模型映射配置
|
|
pub struct ModelMapping {
|
|
pub haiku_model: Option<String>,
|
|
pub sonnet_model: Option<String>,
|
|
pub opus_model: Option<String>,
|
|
pub default_model: Option<String>,
|
|
}
|
|
|
|
impl ModelMapping {
|
|
/// 从 Provider 配置中提取模型映射
|
|
pub fn from_provider(provider: &Provider) -> Self {
|
|
let env = provider.settings_config.get("env");
|
|
|
|
Self {
|
|
haiku_model: env
|
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"))
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from),
|
|
sonnet_model: env
|
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_SONNET_MODEL"))
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from),
|
|
opus_model: env
|
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_OPUS_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())
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from),
|
|
}
|
|
}
|
|
|
|
/// 检查是否配置了任何模型映射
|
|
pub fn has_mapping(&self) -> bool {
|
|
self.haiku_model.is_some()
|
|
|| self.sonnet_model.is_some()
|
|
|| self.opus_model.is_some()
|
|
|| self.default_model.is_some()
|
|
}
|
|
|
|
/// 根据原始模型名称获取映射后的模型
|
|
pub fn map_model(&self, original_model: &str) -> String {
|
|
let model_lower = original_model.to_lowercase();
|
|
|
|
// 1. 按模型类型匹配
|
|
if model_lower.contains("haiku") {
|
|
if let Some(ref m) = self.haiku_model {
|
|
return m.clone();
|
|
}
|
|
}
|
|
if model_lower.contains("opus") {
|
|
if let Some(ref m) = self.opus_model {
|
|
return m.clone();
|
|
}
|
|
}
|
|
if model_lower.contains("sonnet") {
|
|
if let Some(ref m) = self.sonnet_model {
|
|
return m.clone();
|
|
}
|
|
}
|
|
|
|
// 2. 默认模型
|
|
if let Some(ref m) = self.default_model {
|
|
return m.clone();
|
|
}
|
|
|
|
// 3. 无映射,保持原样
|
|
original_model.to_string()
|
|
}
|
|
}
|
|
|
|
/// 对请求体应用模型映射
|
|
///
|
|
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
|
|
pub fn apply_model_mapping(
|
|
mut body: Value,
|
|
provider: &Provider,
|
|
) -> (Value, Option<String>, Option<String>) {
|
|
let mapping = ModelMapping::from_provider(provider);
|
|
|
|
// 如果没有配置映射,直接返回
|
|
if !mapping.has_mapping() {
|
|
let original = body.get("model").and_then(|m| m.as_str()).map(String::from);
|
|
return (body, original, None);
|
|
}
|
|
|
|
// 提取原始模型名
|
|
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
|
|
|
|
if let Some(ref original) = original_model {
|
|
let mapped = mapping.map_model(original);
|
|
|
|
if mapped != *original {
|
|
log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
|
|
body["model"] = serde_json::json!(mapped);
|
|
return (body, Some(original.clone()), Some(mapped));
|
|
}
|
|
}
|
|
|
|
(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::*;
|
|
use serde_json::json;
|
|
|
|
fn create_provider_with_mapping() -> Provider {
|
|
Provider {
|
|
id: "test".to_string(),
|
|
name: "Test".to_string(),
|
|
settings_config: json!({
|
|
"env": {
|
|
"ANTHROPIC_MODEL": "default-model",
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
|
|
}
|
|
}),
|
|
website_url: None,
|
|
category: None,
|
|
created_at: None,
|
|
sort_index: None,
|
|
notes: None,
|
|
meta: None,
|
|
icon: None,
|
|
icon_color: None,
|
|
in_failover_queue: false,
|
|
}
|
|
}
|
|
|
|
fn create_provider_without_mapping() -> Provider {
|
|
Provider {
|
|
id: "test".to_string(),
|
|
name: "Test".to_string(),
|
|
settings_config: json!({}),
|
|
website_url: None,
|
|
category: None,
|
|
created_at: None,
|
|
sort_index: None,
|
|
notes: None,
|
|
meta: None,
|
|
icon: None,
|
|
icon_color: None,
|
|
in_failover_queue: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_sonnet_mapping() {
|
|
let provider = create_provider_with_mapping();
|
|
let body = json!({"model": "claude-sonnet-4-5-20250929"});
|
|
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
|
assert_eq!(result["model"], "sonnet-mapped");
|
|
assert_eq!(original, Some("claude-sonnet-4-5-20250929".to_string()));
|
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_haiku_mapping() {
|
|
let provider = create_provider_with_mapping();
|
|
let body = json!({"model": "claude-haiku-4-5"});
|
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
|
assert_eq!(result["model"], "haiku-mapped");
|
|
assert_eq!(mapped, Some("haiku-mapped".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_opus_mapping() {
|
|
let provider = create_provider_with_mapping();
|
|
let body = json!({"model": "claude-opus-4-5"});
|
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
|
assert_eq!(result["model"], "opus-mapped");
|
|
assert_eq!(mapped, Some("opus-mapped".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_thinking_does_not_affect_model_mapping() {
|
|
// Issue #2081: thinking 参数不应影响模型映射
|
|
let provider = create_provider_with_mapping();
|
|
let body = json!({
|
|
"model": "claude-sonnet-4-5",
|
|
"thinking": {"type": "enabled"}
|
|
});
|
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
|
assert_eq!(result["model"], "sonnet-mapped");
|
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_thinking_adaptive_does_not_affect_model_mapping() {
|
|
// Issue #2081: adaptive thinking 也不应影响模型映射
|
|
let provider = create_provider_with_mapping();
|
|
let body = json!({
|
|
"model": "claude-sonnet-4-5",
|
|
"thinking": {"type": "adaptive"}
|
|
});
|
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
|
assert_eq!(result["model"], "sonnet-mapped");
|
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_thinking_disabled() {
|
|
let provider = create_provider_with_mapping();
|
|
let body = json!({
|
|
"model": "claude-sonnet-4-5",
|
|
"thinking": {"type": "disabled"}
|
|
});
|
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
|
assert_eq!(result["model"], "sonnet-mapped");
|
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_model_uses_default() {
|
|
let provider = create_provider_with_mapping();
|
|
let body = json!({"model": "some-unknown-model"});
|
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
|
assert_eq!(result["model"], "default-model");
|
|
assert_eq!(mapped, Some("default-model".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_mapping_configured() {
|
|
let provider = create_provider_without_mapping();
|
|
let body = json!({"model": "claude-sonnet-4-5"});
|
|
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
|
assert_eq!(result["model"], "claude-sonnet-4-5");
|
|
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
|
|
assert!(mapped.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_case_insensitive() {
|
|
let provider = create_provider_with_mapping();
|
|
let body = json!({"model": "Claude-SONNET-4-5"});
|
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
|
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");
|
|
}
|
|
}
|