//! Claude (Anthropic) Provider Adapter //! //! 支持透传模式和 OpenAI 格式转换模式 //! //! ## API 格式 //! - **anthropic** (默认): Anthropic Messages API 格式,直接透传 //! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换 //! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换 //! //! ## 认证模式 //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) //! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key) //! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传 //! - **GitHubCopilot**: GitHub Copilot (OAuth + Copilot Token) use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use crate::provider::Provider; use crate::proxy::error::ProxyError; use reqwest::RequestBuilder; /// 获取 Claude 供应商的 API 格式 /// /// 供 handler/forwarder 外部使用的公开函数。 /// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic" pub fn get_claude_api_format(provider: &Provider) -> &'static str { // 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config) if let Some(meta) = provider.meta.as_ref() { if let Some(api_format) = meta.api_format.as_deref() { return match api_format { "openai_chat" => "openai_chat", "openai_responses" => "openai_responses", _ => "anthropic", }; } } // 2) Backward compatibility: legacy settings_config.api_format if let Some(api_format) = provider .settings_config .get("api_format") .and_then(|v| v.as_str()) { return match api_format { "openai_chat" => "openai_chat", "openai_responses" => "openai_responses", _ => "anthropic", }; } // 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string) let raw = provider.settings_config.get("openrouter_compat_mode"); let enabled = match raw { Some(serde_json::Value::Bool(v)) => *v, Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0, Some(serde_json::Value::String(value)) => { let normalized = value.trim().to_lowercase(); normalized == "true" || normalized == "1" } _ => false, }; if enabled { "openai_chat" } else { "anthropic" } } /// Claude 适配器 pub struct ClaudeAdapter; impl ClaudeAdapter { pub fn new() -> Self { Self } /// 获取供应商类型 /// /// 根据 base_url 和 auth_mode 检测具体的供应商类型: /// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com /// - OpenRouter: base_url 包含 openrouter.ai /// - ClaudeAuth: auth_mode 为 bearer_only /// - Claude: 默认 Anthropic 官方 pub fn provider_type(&self, provider: &Provider) -> ProviderType { // 检测 GitHub Copilot if self.is_github_copilot(provider) { return ProviderType::GitHubCopilot; } // 检测 OpenRouter if self.is_openrouter(provider) { return ProviderType::OpenRouter; } // 检测 ClaudeAuth (仅 Bearer 认证) if self.is_bearer_only_mode(provider) { return ProviderType::ClaudeAuth; } ProviderType::Claude } /// 检测是否为 GitHub Copilot 供应商 fn is_github_copilot(&self, provider: &Provider) -> bool { // 方式1: 检查 meta.provider_type if let Some(meta) = provider.meta.as_ref() { if meta.provider_type.as_deref() == Some("github_copilot") { return true; } } // 方式2: 检查 base_url(兼容旧数据的 fallback,后续应优先依赖 providerType) if let Ok(base_url) = self.extract_base_url(provider) { if base_url.contains("githubcopilot.com") { return true; } } false } /// 检测是否使用 OpenRouter fn is_openrouter(&self, provider: &Provider) -> bool { if let Ok(base_url) = self.extract_base_url(provider) { return base_url.contains("openrouter.ai"); } false } /// 获取 API 格式 /// /// 从 provider.meta.api_format 读取格式设置: /// - "anthropic" (默认): Anthropic Messages API 格式,直接透传 /// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 /// - "openai_responses": OpenAI Responses API 格式,需要格式转换 fn get_api_format(&self, provider: &Provider) -> &'static str { get_claude_api_format(provider) } /// 检测是否为仅 Bearer 认证模式 fn is_bearer_only_mode(&self, provider: &Provider) -> bool { // 检查 settings_config 中的 auth_mode if let Some(auth_mode) = provider .settings_config .get("auth_mode") .and_then(|v| v.as_str()) { if auth_mode == "bearer_only" { return true; } } // 检查 env 中的 AUTH_MODE if let Some(env) = provider.settings_config.get("env") { if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) { if auth_mode == "bearer_only" { return true; } } } false } /// 从 Provider 配置中提取 API Key fn extract_key(&self, provider: &Provider) -> Option { if let Some(env) = provider.settings_config.get("env") { // Anthropic 标准 key if let Some(key) = env .get("ANTHROPIC_AUTH_TOKEN") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN"); return Some(key.to_string()); } if let Some(key) = env .get("ANTHROPIC_API_KEY") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 ANTHROPIC_API_KEY"); return Some(key.to_string()); } // OpenRouter key if let Some(key) = env .get("OPENROUTER_API_KEY") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 OPENROUTER_API_KEY"); return Some(key.to_string()); } // 备选 OpenAI key (用于 OpenRouter) if let Some(key) = env .get("OPENAI_API_KEY") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 OPENAI_API_KEY"); return Some(key.to_string()); } } // 尝试直接获取 if let Some(key) = provider .settings_config .get("apiKey") .or_else(|| provider.settings_config.get("api_key")) .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 apiKey/api_key"); return Some(key.to_string()); } log::warn!("[Claude] 未找到有效的 API Key"); None } } impl Default for ClaudeAdapter { fn default() -> Self { Self::new() } } impl ProviderAdapter for ClaudeAdapter { fn name(&self) -> &'static str { "Claude" } fn extract_base_url(&self, provider: &Provider) -> Result { // 1. 从 env 中获取 if let Some(env) = provider.settings_config.get("env") { if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) { return Ok(url.trim_end_matches('/').to_string()); } } // 2. 尝试直接获取 if let Some(url) = provider .settings_config .get("base_url") .and_then(|v| v.as_str()) { return Ok(url.trim_end_matches('/').to_string()); } if let Some(url) = provider .settings_config .get("baseURL") .and_then(|v| v.as_str()) { return Ok(url.trim_end_matches('/').to_string()); } if let Some(url) = provider .settings_config .get("apiEndpoint") .and_then(|v| v.as_str()) { return Ok(url.trim_end_matches('/').to_string()); } Err(ProxyError::ConfigError( "Claude Provider 缺少 base_url 配置".to_string(), )) } fn extract_auth(&self, provider: &Provider) -> Option { let provider_type = self.provider_type(provider); // GitHub Copilot 使用特殊的认证策略 // 实际的 token 会在代理请求时动态获取 if provider_type == ProviderType::GitHubCopilot { // 返回一个占位符,实际 token 由 CopilotAuthManager 动态提供 return Some(AuthInfo::new( "copilot_placeholder".to_string(), AuthStrategy::GitHubCopilot, )); } let strategy = match provider_type { ProviderType::OpenRouter => AuthStrategy::Bearer, ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth, _ => AuthStrategy::Anthropic, }; self.extract_key(provider) .map(|key| AuthInfo::new(key, strategy)) } fn build_url(&self, base_url: &str, endpoint: &str) -> String { // NOTE: // 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages` // 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。 // // 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。 // 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。 let mut base = format!( "{}/{}", base_url.trim_end_matches('/'), endpoint.trim_start_matches('/') ); // 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致) while base.contains("/v1/v1") { base = base.replace("/v1/v1", "/v1"); } // GitHub Copilot 不需要 ?beta=true 参数 if base_url.contains("githubcopilot.com") { return base; } // 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数 // 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数 // 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数 // 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions, // 但该端点是 OpenAI 标准,不支持 ?beta=true 参数 if endpoint.contains("/v1/messages") && !endpoint.contains("/v1/chat/completions") && !endpoint.contains('?') { format!("{base}?beta=true") } else { base } } fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { // 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值) // 这里不再设置 anthropic-version,避免 header 重复 match auth.strategy { // Anthropic 官方: Authorization Bearer + x-api-key AuthStrategy::Anthropic => request .header("Authorization", format!("Bearer {}", auth.api_key)) .header("x-api-key", &auth.api_key), // ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key AuthStrategy::ClaudeAuth => { request.header("Authorization", format!("Bearer {}", auth.api_key)) } // OpenRouter: Bearer AuthStrategy::Bearer => { request.header("Authorization", format!("Bearer {}", auth.api_key)) } // GitHub Copilot: Bearer + 统一指纹头 AuthStrategy::GitHubCopilot => request .header("Authorization", format!("Bearer {}", auth.api_key)) .header("editor-version", super::copilot_auth::COPILOT_EDITOR_VERSION) .header("editor-plugin-version", super::copilot_auth::COPILOT_PLUGIN_VERSION) .header("copilot-integration-id", super::copilot_auth::COPILOT_INTEGRATION_ID) .header("user-agent", super::copilot_auth::COPILOT_USER_AGENT) .header("x-github-api-version", super::copilot_auth::COPILOT_API_VERSION) .header("openai-intent", "conversation-panel"), _ => request, } } fn needs_transform(&self, provider: &Provider) -> bool { // GitHub Copilot 总是需要格式转换 (Anthropic → OpenAI) if self.is_github_copilot(provider) { return true; } // 根据 api_format 配置决定是否需要格式转换 // - "anthropic" (默认): 直接透传,无需转换 // - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换 // - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换 matches!( self.get_api_format(provider), "openai_chat" | "openai_responses" ) } fn transform_request( &self, body: serde_json::Value, provider: &Provider, ) -> Result { // Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id let cache_key = provider .meta .as_ref() .and_then(|m| m.prompt_cache_key.as_deref()) .unwrap_or(&provider.id); match self.get_api_format(provider) { "openai_responses" => { super::transform_responses::anthropic_to_responses(body, Some(cache_key)) } _ => super::transform::anthropic_to_openai(body, Some(cache_key)), } } fn transform_response(&self, body: serde_json::Value) -> Result { // Heuristic: detect response format by presence of top-level fields. // The ProviderAdapter trait's transform_response doesn't receive the Provider // config, so we can't check api_format here. Instead we rely on the fact that // Responses API always returns "output" while Chat Completions returns "choices". // This is safe because the two formats are structurally disjoint. if body.get("output").is_some() { super::transform_responses::responses_to_anthropic(body) } else { super::transform::openai_to_anthropic(body) } } } #[cfg(test)] mod tests { use super::*; use crate::provider::ProviderMeta; use serde_json::json; fn create_provider(config: serde_json::Value) -> Provider { Provider { id: "test".to_string(), name: "Test Claude".to_string(), settings_config: config, website_url: None, category: Some("claude".to_string()), created_at: None, sort_index: None, notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } fn create_provider_with_meta(config: serde_json::Value, meta: ProviderMeta) -> Provider { Provider { id: "test".to_string(), name: "Test Claude".to_string(), settings_config: config, website_url: None, category: Some("claude".to_string()), created_at: None, sort_index: None, notes: None, meta: Some(meta), icon: None, icon_color: None, in_failover_queue: false, } } #[test] fn test_extract_base_url_from_env() { let adapter = ClaudeAdapter::new(); let provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com" } })); let url = adapter.extract_base_url(&provider).unwrap(); assert_eq!(url, "https://api.anthropic.com"); } #[test] fn test_extract_auth_anthropic() { let adapter = ClaudeAdapter::new(); let provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key" } })); let auth = adapter.extract_auth(&provider).unwrap(); assert_eq!(auth.api_key, "sk-ant-test-key"); assert_eq!(auth.strategy, AuthStrategy::Anthropic); } #[test] fn test_extract_auth_anthropic_api_key() { let adapter = ClaudeAdapter::new(); let provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "sk-ant-test-key" } })); let auth = adapter.extract_auth(&provider).unwrap(); assert_eq!(auth.api_key, "sk-ant-test-key"); assert_eq!(auth.strategy, AuthStrategy::Anthropic); } #[test] fn test_extract_auth_openrouter() { let adapter = ClaudeAdapter::new(); let provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", "OPENROUTER_API_KEY": "sk-or-test-key" } })); let auth = adapter.extract_auth(&provider).unwrap(); assert_eq!(auth.api_key, "sk-or-test-key"); assert_eq!(auth.strategy, AuthStrategy::Bearer); } #[test] fn test_extract_auth_claude_auth_mode() { let adapter = ClaudeAdapter::new(); let provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://some-proxy.com", "ANTHROPIC_AUTH_TOKEN": "sk-proxy-key" }, "auth_mode": "bearer_only" })); let auth = adapter.extract_auth(&provider).unwrap(); assert_eq!(auth.api_key, "sk-proxy-key"); assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth); } #[test] fn test_extract_auth_claude_auth_env_mode() { let adapter = ClaudeAdapter::new(); let provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://some-proxy.com", "ANTHROPIC_AUTH_TOKEN": "sk-proxy-key", "AUTH_MODE": "bearer_only" } })); let auth = adapter.extract_auth(&provider).unwrap(); assert_eq!(auth.api_key, "sk-proxy-key"); assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth); } #[test] fn test_provider_type_detection() { let adapter = ClaudeAdapter::new(); // Anthropic 官方 let anthropic = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_AUTH_TOKEN": "sk-ant-test" } })); assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude); // OpenRouter let openrouter = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", "OPENROUTER_API_KEY": "sk-or-test" } })); assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter); // ClaudeAuth let claude_auth = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://some-proxy.com", "ANTHROPIC_AUTH_TOKEN": "sk-test" }, "auth_mode": "bearer_only" })); assert_eq!( adapter.provider_type(&claude_auth), ProviderType::ClaudeAuth ); } #[test] fn test_build_url_anthropic() { let adapter = ClaudeAdapter::new(); // /v1/messages 端点会自动添加 ?beta=true 参数 let url = adapter.build_url("https://api.anthropic.com", "/v1/messages"); assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true"); } #[test] fn test_build_url_openrouter() { let adapter = ClaudeAdapter::new(); // /v1/messages 端点会自动添加 ?beta=true 参数 let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true"); } #[test] fn test_build_url_no_beta_for_other_endpoints() { let adapter = ClaudeAdapter::new(); // 非 /v1/messages 端点不添加 ?beta=true let url = adapter.build_url("https://api.anthropic.com", "/v1/complete"); assert_eq!(url, "https://api.anthropic.com/v1/complete"); } #[test] fn test_build_url_preserve_existing_query() { let adapter = ClaudeAdapter::new(); // 已有查询参数时不重复添加 let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar"); assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar"); } #[test] fn test_build_url_no_beta_for_openai_chat_completions() { let adapter = ClaudeAdapter::new(); // OpenAI Chat Completions 端点不添加 ?beta=true // 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点 let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions"); assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions"); } #[test] fn test_needs_transform() { let adapter = ClaudeAdapter::new(); // Default: no transform (anthropic format) - no meta let anthropic_provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com" } })); assert!(!adapter.needs_transform(&anthropic_provider)); // Explicit anthropic format in meta: no transform let explicit_anthropic = create_provider_with_meta( json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" } }), ProviderMeta { api_format: Some("anthropic".to_string()), ..Default::default() }, ); assert!(!adapter.needs_transform(&explicit_anthropic)); // Legacy settings_config.api_format: openai_chat should enable transform let legacy_settings_api_format = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" }, "api_format": "openai_chat" })); assert!(adapter.needs_transform(&legacy_settings_api_format)); // Legacy openrouter_compat_mode: bool/number/string should enable transform let legacy_openrouter_bool = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" }, "openrouter_compat_mode": true })); assert!(adapter.needs_transform(&legacy_openrouter_bool)); let legacy_openrouter_num = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" }, "openrouter_compat_mode": 1 })); assert!(adapter.needs_transform(&legacy_openrouter_num)); let legacy_openrouter_str = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" }, "openrouter_compat_mode": "true" })); assert!(adapter.needs_transform(&legacy_openrouter_str)); // OpenAI Chat format in meta: needs transform let openai_chat_provider = create_provider_with_meta( json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" } }), ProviderMeta { api_format: Some("openai_chat".to_string()), ..Default::default() }, ); assert!(adapter.needs_transform(&openai_chat_provider)); // OpenAI Responses format in meta: needs transform let openai_responses_provider = create_provider_with_meta( json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" } }), ProviderMeta { api_format: Some("openai_responses".to_string()), ..Default::default() }, ); assert!(adapter.needs_transform(&openai_responses_provider)); // meta takes precedence over legacy settings_config fields let meta_precedence_over_settings = create_provider_with_meta( json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" }, "api_format": "openai_chat", "openrouter_compat_mode": true }), ProviderMeta { api_format: Some("anthropic".to_string()), ..Default::default() }, ); assert!(!adapter.needs_transform(&meta_precedence_over_settings)); // Unknown format in meta: default to anthropic (no transform) let unknown_format = create_provider_with_meta( json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" } }), ProviderMeta { api_format: Some("unknown".to_string()), ..Default::default() }, ); assert!(!adapter.needs_transform(&unknown_format)); } #[test] fn test_github_copilot_detection_by_url() { let adapter = ClaudeAdapter::new(); // GitHub Copilot by base_url let copilot = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com" } })); assert_eq!(adapter.provider_type(&copilot), ProviderType::GitHubCopilot); } #[test] fn test_github_copilot_detection_by_meta() { let adapter = ClaudeAdapter::new(); // GitHub Copilot by meta.provider_type let copilot_meta = create_provider_with_meta( json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" } }), ProviderMeta { provider_type: Some("github_copilot".to_string()), ..Default::default() }, ); assert_eq!( adapter.provider_type(&copilot_meta), ProviderType::GitHubCopilot ); } #[test] fn test_github_copilot_auth() { let adapter = ClaudeAdapter::new(); let copilot = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com" } })); let auth = adapter.extract_auth(&copilot).unwrap(); assert_eq!(auth.strategy, AuthStrategy::GitHubCopilot); } #[test] fn test_github_copilot_needs_transform() { let adapter = ClaudeAdapter::new(); let copilot = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com" } })); // GitHub Copilot always needs transform assert!(adapter.needs_transform(&copilot)); } }