mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
e4c34b34e7
ANTHROPIC_REASONING_MODEL was a non-official env var that forced all requests with thinking params to use a single "reasoning model", overriding the user's /model selection. Since new Claude Code versions send adaptive thinking by default, this caused /model to silently fail. - Remove reasoning_model field and has_thinking_enabled() from model_mapper - Simplify map_model() to pure type-based matching (haiku/sonnet/opus) - Remove reasoning model UI field from provider form - Retain ANTHROPIC_REASONING_MODEL in ENV_EXCLUDES and override-key cleanup lists so legacy configs don't leak into common config
255 lines
8.4 KiB
Rust
255 lines
8.4 KiB
Rust
//! 模型映射模块
|
|
//!
|
|
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
|
|
|
|
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)
|
|
}
|
|
|
|
#[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()));
|
|
}
|
|
}
|