use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::OnceLock; use super::traits::Usage; use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Tool, ToolCall}; use crate::domain::messages::ContentBlock; use crate::utils::format_error_chain; const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &["supported_content_types"]; fn serialize_content_blocks( blocks: &[serde_json::Value], serializer: S, ) -> Result where S: serde::Serializer, { serializer.serialize_str(&serde_json::to_string(blocks).unwrap_or_else(|_| "[]".to_string())) } fn convert_content_blocks( supports_images: bool, provider_name: &str, model_id: &str, blocks: &[ContentBlock], message_idx: usize, ) -> Vec { // 检查是否有图片且模型不支持 if !supports_images { let has_images = blocks .iter() .any(|b| matches!(b, ContentBlock::ImageUrl { .. })); if has_images { let image_count = blocks .iter() .filter(|b| matches!(b, ContentBlock::ImageUrl { .. })) .count(); tracing::warn!( provider = %provider_name, model = %model_id, filtered_images = image_count, message_idx, "模型不支持图片;将图片转换为通知文本" ); // 复用通知格式,将图片转换为文本通知 let mut converted_blocks: Vec = Vec::new(); let mut notices: Vec = Vec::new(); let mut image_idx = 0; for block in blocks.iter() { match block { ContentBlock::Text { text } => { converted_blocks.push(serde_json::json!({ "type": "text", "text": text })); } ContentBlock::ImageUrl { .. } => { image_idx += 1; notices.push(format!( "- 第 {} 张图片:当前模型不支持图片输入,该图片未能成功入模,请直接告知用户。", image_idx )); } } } // 添加通知文本块 if !notices.is_empty() { let notice_text = format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n")); converted_blocks.push(serde_json::json!({ "type": "text", "text": notice_text })); } return converted_blocks; } } // 原有逻辑 - 模型支持图片,正常转换 blocks .iter() .map(|b| match b { ContentBlock::Text { text } => { serde_json::json!({ "type": "text", "text": text }) } ContentBlock::ImageUrl { image_url } => convert_image_url_to_anthropic(&image_url.url), }) .collect() } fn convert_image_url_to_anthropic(url: &str) -> serde_json::Value { // data:image/png;base64,... -> Anthropic image block static RE: OnceLock = OnceLock::new(); let re = RE.get_or_init(|| regex::Regex::new(r"data:(image/\w+);base64,(.+)").expect("valid regex")); if let Some(caps) = re.captures(url) { let media_type = caps.get(1).map(|m| m.as_str()).unwrap_or("image/png"); let data = caps.get(2).map(|d| d.as_str()).unwrap_or(""); return serde_json::json!({ "type": "image", "source": { "type": "base64", "media_type": media_type, "data": data } }); } // Regular URL -> Anthropic image block with url source serde_json::json!({ "type": "image", "source": { "type": "url", "url": url } }) } pub struct AnthropicProvider { client: Client, name: String, api_key: String, base_url: String, extra_headers: HashMap, llm_timeout_secs: u64, model_id: String, temperature: Option, max_tokens: Option, model_extra: HashMap, } impl AnthropicProvider { pub fn new( name: String, api_key: String, base_url: String, extra_headers: HashMap, llm_timeout_secs: u64, model_id: String, temperature: Option, max_tokens: Option, model_extra: HashMap, ) -> Self { // 复用按超时配置的共享 client(TLS 上下文 + 连接池), // 避免每条消息重建 Provider 时重复构造。 let client = crate::providers::shared_llm_http_client(llm_timeout_secs); // 兼容带末尾斜杠的 base_url,避免 format!("{}/v1/messages", base_url) 产生双斜杠 let base_url = base_url.trim_end_matches('/').to_string(); Self { client, name, api_key, base_url, extra_headers, llm_timeout_secs, model_id, temperature, max_tokens, model_extra, } } /// 检查模型是否支持指定内容类型 /// 默认支持所有类型(text, image) fn supports_content_type(&self, content_type: &str) -> bool { self.model_extra .get("supported_content_types") .and_then(|value| value.as_array()) .map(|types| types.iter().any(|t| t.as_str() == Some(content_type))) .unwrap_or(true) } /// 检查模型是否支持图片 fn supports_images(&self) -> bool { self.supports_content_type("image") } /// 过滤掉内部字段,只返回需要发送到 API 的 extra 字段 fn request_model_extra(&self) -> HashMap { self.model_extra .iter() .filter(|(key, _)| !INTERNAL_MODEL_EXTRA_KEYS.contains(&key.as_str())) .map(|(k, v)| (k.clone(), v.clone())) .collect() } } #[derive(Serialize)] struct AnthropicRequest { model: String, messages: Vec, max_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")] temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] tools: Option>, #[serde(flatten)] extra: HashMap, } #[derive(Serialize)] struct AnthropicMessage { role: String, #[serde(serialize_with = "serialize_content_blocks")] content: Vec, } #[derive(Serialize)] struct AnthropicTool { name: String, description: String, input_schema: serde_json::Value, } #[derive(Deserialize)] struct AnthropicResponse { id: String, model: String, content: Vec, usage: AnthropicUsage, } #[derive(Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum AnthropicContent { Text { text: String, }, #[allow(dead_code)] Thinking { thinking: String, }, #[serde(rename = "tool_use")] ToolUse { id: String, name: String, input: serde_json::Value, }, } #[derive(Deserialize)] struct AnthropicUsage { input_tokens: u32, output_tokens: u32, /// 从服务端缓存读取的输入 tokens 数(命中缓存部分) #[serde(default)] cache_read_input_tokens: Option, } #[async_trait] impl LLMProvider for AnthropicProvider { #[tracing::instrument(skip(self, request), fields(provider = %self.name, model = %self.model_id))] async fn chat( &self, request: &ChatCompletionRequest, ) -> Result> { let url = format!("{}/v1/messages", self.base_url); let max_tokens = request.max_tokens.or(self.max_tokens).unwrap_or(8192); tracing::info!( provider = %self.name, model = %self.model_id, message_count = request.messages.len(), has_tools = request.tools.is_some(), "Anthropic: sending chat completion request" ); let tools = request.tools.as_ref().map(|tools| { tools .iter() .map(|t: &Tool| AnthropicTool { name: t.function.name.clone(), description: t.function.description.clone(), input_schema: t.function.parameters.clone(), }) .collect() }); let body = AnthropicRequest { model: self.model_id.clone(), messages: request .messages .iter() .enumerate() .map(|(i, m)| AnthropicMessage { role: m.role.clone(), content: convert_content_blocks( self.supports_images(), &self.name, &self.model_id, &m.content, i, ), }) .collect(), max_tokens, temperature: request.temperature.or(self.temperature), tools, extra: self.request_model_extra(), }; let mut req_builder = self .client .post(&url) .header("x-api-key", &self.api_key) .header("anthropic-version", "2023-06-01") .header("Content-Type", "application/json"); for (key, value) in &self.extra_headers { req_builder = req_builder.header(key.as_str(), value.as_str()); } let resp = req_builder.json(&body).send().await.inspect_err(|e| { tracing::error!( provider = %self.name, model = %self.model_id, url = %url, timeout_secs = self.llm_timeout_secs, error = %format_error_chain(e), "Anthropic: HTTP request failed" ); })?; let status = resp.status(); let text = resp.text().await?; if !status.is_success() { tracing::error!( provider = %self.name, model = %self.model_id, url = %url, status = %status, response_len = text.len(), response_body = %text, "Anthropic API request failed" ); return Err(format!("API error {}: {}", status, text).into()); } tracing::debug!( provider = %self.name, model = %self.model_id, status = %status, response_len = text.len(), "Anthropic response received" ); let anthropic_resp: AnthropicResponse = serde_json::from_str(&text).map_err(|e| { tracing::error!( provider = %self.name, model = %self.model_id, url = %url, error = %format_error_chain(&e), response_len = text.len(), response_body = %text, "Failed to decode Anthropic response" ); format!("decode error: {} | body: {}", e, &text) })?; let mut content = String::new(); let mut tool_calls = Vec::new(); for c in &anthropic_resp.content { match c { AnthropicContent::Text { text } => { if !text.is_empty() { if !content.is_empty() { content.push('\n'); } content.push_str(text); } } AnthropicContent::Thinking { .. } => {} AnthropicContent::ToolUse { id, name, input } => { tool_calls.push(ToolCall { id: id.clone(), name: name.clone(), arguments: input.clone(), }); } } } let usage = Usage { prompt_tokens: anthropic_resp.usage.input_tokens, completion_tokens: anthropic_resp.usage.output_tokens, total_tokens: anthropic_resp.usage.input_tokens + anthropic_resp.usage.output_tokens, cached_tokens: anthropic_resp.usage.cache_read_input_tokens.unwrap_or(0), }; tracing::info!( provider = %self.name, model = %self.model_id, prompt_tokens = usage.prompt_tokens, completion_tokens = usage.completion_tokens, total_tokens = usage.total_tokens, has_tool_calls = !tool_calls.is_empty(), "Anthropic: chat completion completed" ); Ok(ChatCompletionResponse { id: anthropic_resp.id, model: anthropic_resp.model, content, reasoning_content: None, tool_calls, usage, }) } fn ptype(&self) -> &str { "anthropic" } fn name(&self) -> &str { &self.name } fn model_id(&self) -> &str { &self.model_id } } #[cfg(test)] mod tests { use super::*; use crate::domain::messages::ContentBlock; use std::collections::HashMap; /// 构造一个最小 Provider 用于测试配置驱动的方法 fn make_provider(model_extra: HashMap) -> AnthropicProvider { AnthropicProvider::new( "test".to_string(), "key".to_string(), "https://api.test".to_string(), HashMap::new(), 30, "claude-test".to_string(), None, None, model_extra, ) } // ---- convert_image_url_to_anthropic ---- #[test] fn test_convert_data_url_extracts_media_type_and_base64() { let url = "data:image/png;base64,iVBORw0KGgo="; let v = convert_image_url_to_anthropic(url); assert_eq!(v["type"], "image"); assert_eq!(v["source"]["type"], "base64"); assert_eq!(v["source"]["media_type"], "image/png"); assert_eq!(v["source"]["data"], "iVBORw0KGgo="); } #[test] fn test_convert_data_url_jpeg() { let url = "data:image/jpeg;base64,/9j/4AAQ"; let v = convert_image_url_to_anthropic(url); assert_eq!(v["source"]["media_type"], "image/jpeg"); assert_eq!(v["source"]["data"], "/9j/4AAQ"); } #[test] fn test_convert_regular_url_uses_url_source() { let url = "https://example.com/img.png"; let v = convert_image_url_to_anthropic(url); assert_eq!(v["type"], "image"); assert_eq!(v["source"]["type"], "url"); assert_eq!(v["source"]["url"], url); } // ---- convert_content_blocks: 图片不支持时的过滤 ---- #[test] fn test_convert_blocks_filters_images_when_unsupported() { let blocks = vec![ ContentBlock::text("hello"), ContentBlock::image_url("data:image/png;base64,abc"), ContentBlock::image_url("data:image/png;base64,def"), ]; let result = convert_content_blocks(false, "test", "claude-test", &blocks, 0); // 文本块保留,图片块被替换为通知 assert_eq!(result.len(), 2); assert_eq!(result[0]["type"], "text"); assert_eq!(result[0]["text"], "hello"); // 第二个是合并的图片通知 assert_eq!(result[1]["type"], "text"); let notice = result[1]["text"].as_str().unwrap(); assert!(notice.contains("第 1 张图片")); assert!(notice.contains("第 2 张图片")); } #[test] fn test_convert_blocks_keeps_images_when_supported() { let blocks = vec![ ContentBlock::text("hi"), ContentBlock::image_url("data:image/png;base64,abc"), ]; let result = convert_content_blocks(true, "test", "claude-test", &blocks, 0); assert_eq!(result.len(), 2); assert_eq!(result[0]["type"], "text"); assert_eq!(result[1]["type"], "image"); assert_eq!(result[1]["source"]["data"], "abc"); } #[test] fn test_convert_blocks_text_only_passthrough() { let blocks = vec![ContentBlock::text("just text")]; let result = convert_content_blocks(false, "test", "claude-test", &blocks, 0); assert_eq!(result.len(), 1); assert_eq!(result[0]["type"], "text"); } // ---- request_model_extra: 内部字段过滤 ---- #[test] fn test_request_model_extra_filters_internal_keys() { let mut extra = HashMap::new(); extra.insert( "supported_content_types".to_string(), serde_json::json!(["text"]), ); extra.insert("top_p".to_string(), serde_json::json!(0.9)); let provider = make_provider(extra); let filtered = provider.request_model_extra(); // 内部字段被过滤 assert!(!filtered.contains_key("supported_content_types")); // 业务字段保留 assert_eq!(filtered.get("top_p").and_then(|v| v.as_f64()), Some(0.9)); } #[test] fn test_request_model_extra_empty_when_only_internal() { let mut extra = HashMap::new(); extra.insert( "supported_content_types".to_string(), serde_json::json!(["text", "image"]), ); let provider = make_provider(extra); assert!(provider.request_model_extra().is_empty()); } // ---- supports_images: 配置驱动 ---- #[test] fn test_supports_images_default_true() { let provider = make_provider(HashMap::new()); assert!(provider.supports_images()); } #[test] fn test_supports_images_disabled_via_config() { let mut extra = HashMap::new(); extra.insert( "supported_content_types".to_string(), serde_json::json!(["text"]), ); let provider = make_provider(extra); assert!(!provider.supports_images()); } // ---- AnthropicResponse 反序列化 ---- #[test] fn test_deserialize_response_with_text_and_tool_use() { let json = r#"{ "id": "msg_001", "model": "claude-3-sonnet", "content": [ {"type": "text", "text": "I'll use a tool"}, {"type": "tool_use", "id": "call_1", "name": "bash", "input": {"cmd": "ls"}} ], "usage": {"input_tokens": 10, "output_tokens": 20} }"#; let resp: AnthropicResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.id, "msg_001"); assert_eq!(resp.content.len(), 2); match &resp.content[0] { AnthropicContent::Text { text } => assert_eq!(text, "I'll use a tool"), _ => panic!("expected Text"), } match &resp.content[1] { AnthropicContent::ToolUse { id, name, input } => { assert_eq!(id, "call_1"); assert_eq!(name, "bash"); assert_eq!(input["cmd"], "ls"); } _ => panic!("expected ToolUse"), } assert_eq!(resp.usage.input_tokens, 10); assert_eq!(resp.usage.output_tokens, 20); } #[test] fn test_deserialize_response_thinking_variant() { let json = r#"{ "id": "msg_002", "model": "claude-3", "content": [ {"type": "thinking", "thinking": "internal reasoning"} ], "usage": {"input_tokens": 5, "output_tokens": 5} }"#; let resp: AnthropicResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.content.len(), 1); match &resp.content[0] { AnthropicContent::Thinking { thinking } => { assert_eq!(thinking, "internal reasoning"); } _ => panic!("expected Thinking"), } } #[test] fn test_deserialize_response_empty_content() { let json = r#"{ "id": "msg_003", "model": "claude-3", "content": [], "usage": {"input_tokens": 1, "output_tokens": 1} }"#; let resp: AnthropicResponse = serde_json::from_str(json).unwrap(); assert!(resp.content.is_empty()); } // ---- AnthropicRequest 序列化:未配置的可选字段不出现在请求体中 ---- fn make_request(temperature: Option) -> AnthropicRequest { AnthropicRequest { model: "glm-5".to_string(), messages: vec![AnthropicMessage { role: "user".to_string(), content: vec![serde_json::json!({"type": "text", "text": "hi"})], }], max_tokens: 1024, temperature, tools: None, extra: HashMap::new(), } } #[test] fn test_request_omits_temperature_when_none() { let json = serde_json::to_value(make_request(None)).unwrap(); let obj = json.as_object().unwrap(); assert!(!obj.contains_key("temperature")); assert_eq!(obj.get("max_tokens").and_then(|v| v.as_u64()), Some(1024)); } #[test] fn test_request_includes_temperature_when_set() { let json = serde_json::to_value(make_request(Some(0.5))).unwrap(); assert_eq!(json["temperature"], serde_json::json!(0.5)); } // ---- format_error_chain ---- #[test] fn test_format_error_chain_single() { let err = std::io::Error::other("single error"); let chain = format_error_chain(&err); assert_eq!(chain, "single error"); } /// 用 thiserror 构造真正的嵌套 source 链,验证 "caused by" 拼接 #[derive(Debug, thiserror::Error)] enum OuterError { #[error("outer wrapper")] Wrapped(#[source] std::io::Error), } #[test] fn test_format_error_chain_nested() { let inner = std::io::Error::other("root cause"); let outer = OuterError::Wrapped(inner); let chain = format_error_chain(&outer); assert!(chain.contains("outer wrapper")); assert!(chain.contains("caused by")); assert!(chain.contains("root cause")); } }