//! 模型映射模块 //! //! 在请求转发前,根据 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, pub sonnet_model: Option, pub opus_model: Option, pub default_model: Option, } 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, Option) { 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"); } }