PicoBot/src/providers/anthropic.rs
oudecheng 0159227828 perf: 第二批性能修复——blocking 线程池隔离、HTTP 客户端复用、前端 memo 与流式节流
- 同步阻塞操作(附件处理、历史加载、scheduler/memory_search 的 SQLite 调用)
  移入 spawn_blocking,避免占用 async worker
- LLM Provider reqwest::Client 按超时配置缓存复用,减少 TLS/连接开销
- agent loop:图片过滤加廉价预判避免全量深拷贝;请求克隆改借用;工具定义 Arc 化
- 定向 COUNT/LIMIT 1 查询替代全量加载计数(wait_coordinator、task session 重建)
- 前端:面板/侧栏/聊天组件 memo 化;merged_tool 对象按值复用缓存;
  流式 delta rAF 节流批量 flush;useMemo 缓存分组排序结果
2026-08-18 06:55:21 +08:00

688 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<S>(
blocks: &[serde_json::Value],
serializer: S,
) -> Result<S::Ok, S::Error>
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<serde_json::Value> {
// 检查是否有图片且模型不支持
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<serde_json::Value> = Vec::new();
let mut notices: Vec<String> = 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<regex::Regex> = 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<String, String>,
llm_timeout_secs: u64,
model_id: String,
temperature: Option<f32>,
max_tokens: Option<u32>,
model_extra: HashMap<String, serde_json::Value>,
}
impl AnthropicProvider {
pub fn new(
name: String,
api_key: String,
base_url: String,
extra_headers: HashMap<String, String>,
llm_timeout_secs: u64,
model_id: String,
temperature: Option<f32>,
max_tokens: Option<u32>,
model_extra: HashMap<String, serde_json::Value>,
) -> Self {
// 复用按超时配置的共享 clientTLS 上下文 + 连接池),
// 避免每条消息重建 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<String, serde_json::Value> {
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<AnthropicMessage>,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<AnthropicTool>>,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
}
#[derive(Serialize)]
struct AnthropicMessage {
role: String,
#[serde(serialize_with = "serialize_content_blocks")]
content: Vec<serde_json::Value>,
}
#[derive(Serialize)]
struct AnthropicTool {
name: String,
description: String,
input_schema: serde_json::Value,
}
#[derive(Deserialize)]
struct AnthropicResponse {
id: String,
model: String,
content: Vec<AnthropicContent>,
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<u32>,
}
#[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<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
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<String, serde_json::Value>) -> 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<f32>) -> 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"));
}
}