//! Anthropic 兼容 Provider — 通过 /v1/messages 端点实现 //! //! 覆盖: Claude 官方 / GLM 订阅端点 (open.bigmodel.cn/api/anthropic) / 任意 Messages API 网关 //! 支持: 同步调用 + SSE 流式 + Tool Use //! //! 与 OpenAI 协议的关键差异由本模块内部完成转换,对外仍暴露统一的 LlmProvider trait, //! 上层 (Agentic Loop / AiNode) 无需感知协议。 use async_trait::async_trait; use eventsource_stream::Eventsource; use futures::StreamExt; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::time::Duration; use tracing::{debug, error, warn}; use crate::provider::{ CompletionRequest, CompletionResponse, LlmProvider, MessageRole, StreamChunk, StreamResult, TokenUsage, ToolCall, ToolCallDelta, }; use crate::retry::{ retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable, }; // ============================================================ // Anthropic API 请求/响应结构体 // ============================================================ /// Anthropic 请求体 #[derive(Debug, Clone, Serialize)] struct AnthropicRequest { model: String, messages: Vec, max_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")] system: Option, #[serde(skip_serializing_if = "Option::is_none")] temperature: Option, stream: bool, #[serde(skip_serializing_if = "Option::is_none")] tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] tool_choice: Option, } /// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters) #[derive(Debug, Clone, Serialize)] struct AnthropicToolDef { name: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, input_schema: serde_json::Value, } /// Anthropic 同步响应 #[derive(Debug, Deserialize)] struct AnthropicResponse { #[allow(dead_code)] id: String, model: String, content: Vec, #[allow(dead_code)] stop_reason: Option, usage: AnthropicUsage, } /// 响应 content 块(text 或 tool_use) #[derive(Debug, Deserialize)] struct AnthropicContentBlock { #[serde(rename = "type")] block_type: String, #[serde(default)] text: Option, /// tool_use 块字段 id: Option, name: Option, input: Option, } #[derive(Debug, Deserialize)] struct AnthropicUsage { input_tokens: u32, output_tokens: u32, } // ============================================================ // SSE 解析纯函数(与 HTTP 解耦,便于单测) // ============================================================ /// 将一条 Anthropic Messages SSE 事件 data 解析为 StreamChunk,并按需更新 usage 累加器。 /// /// 按 `type` 字段分发: /// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。 /// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。 /// - `content_block_delta` (text_delta/input_json_delta) → 文本/工具入参增量。 /// - `content_block_start` (tool_use) → 工具块开始,带 id+name。 /// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。 /// - `error` → 返回 `finished=true` 终态空 chunk。 /// - 其它(content_block_stop / ping 等)→ 空 chunk。 /// /// 等价性:content_block / message_stop / error 等事件分支与原 stream() 闭包逐字一致; /// usage 透传(message_stop 终态 take() 带出、message_delta 的 output_tokens 按累计值覆盖) /// 为本次新增能力,对应 StreamChunk 新增的 usage 字段。 pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option) -> StreamChunk { // 解析 data 中的 JSON,按 type 字段决定如何转 StreamChunk let v: serde_json::Value = match serde_json::from_str(data) { Ok(v) => v, Err(_) => { return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None } } }; let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); match ty { // 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0) "message_start" => { if let Some(inp) = v .get("message") .and_then(|m| m.get("usage")) .and_then(|u| u.get("input_tokens")) .and_then(|t| t.as_u64()) { *usage_accum = Some(TokenUsage { prompt_tokens: inp as u32, completion_tokens: 0, total_tokens: inp as u32, }); } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None } } // 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total "message_delta" => { if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) { let acc = usage_accum .get_or_insert(TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); acc.completion_tokens = out as u32; acc.total_tokens = acc.prompt_tokens + acc.completion_tokens; } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None } } // 文本增量 "content_block_delta" => { if let Some(delta) = v.get("delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") { let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string(); return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None }; } // 工具入参增量 if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") { let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string(); let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32; return StreamChunk { delta: String::new(), finished: false, tool_calls: Some(vec![ToolCallDelta { index: idx, id: None, function_name: None, function_arguments: Some(partial), }]), usage: None, error: None, }; } } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None } } // 工具块开始:带 id + name "content_block_start" => { if let Some(cb) = v.get("content_block") { if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") { let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32; let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string()); // id 缺失时用占位 id 兜底:流式后续 input_json_delta 按 index 累加, // 中途无法整体跳过;占位 id 保证回传的 tool_use_id 非空,避免 GLM 500。 let id = match cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string()) { Some(id) if !id.is_empty() => Some(id), _ => { let placeholder = format!("tool_missing_{}", idx); warn!(%placeholder, name = ?name, "Anthropic 流式 tool_use 块缺少 id,已填占位 id(原样回传会触发 GLM 500)"); Some(placeholder) } }; return StreamChunk { delta: String::new(), finished: false, tool_calls: Some(vec![ToolCallDelta { index: idx, id, function_name: name, function_arguments: None, }]), usage: None, error: None, }; } } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None } } // 消息结束:带出累积 usage "message_stop" => StreamChunk { delta: String::new(), finished: true, tool_calls: None, usage: usage_accum.take(), error: None, }, // 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库), // 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。 "error" => { let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string(); error!(%msg, "Anthropic 流式错误事件"); StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg) } } // content_block_stop / ping 等不产出 chunk _ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }, } } // ============================================================ // Provider 实现 // ============================================================ /// Anthropic 兼容 LLM Provider pub struct AnthropicCompatProvider { client: Client, api_key: String, base_url: String, default_model: String, } /// Anthropic 流式协议版本头 const ANTHROPIC_VERSION: &str = "2023-06-01"; /// Anthropic max_tokens 必填,缺省时的兜底值 const DEFAULT_MAX_TOKENS: u32 = 4096; impl AnthropicCompatProvider { /// 创建 Provider /// /// - `base_url`: 如 `https://api.anthropic.com`、`https://open.bigmodel.cn/api/anthropic` /// - `api_key`: API 密钥(Anthropic 用 x-api-key 头,非 Bearer) /// - `default_model`: 默认模型名称 pub fn new(base_url: impl Into, api_key: impl Into, default_model: impl Into) -> Self { let client = Client::builder() .connect_timeout(std::time::Duration::from_secs(30)) .build() .unwrap_or_else(|e| { warn!("reqwest builder 失败,回退默认 client: {}", e); Client::new() }); Self { client, api_key: api_key.into(), base_url: base_url.into(), default_model: default_model.into(), } } /// 构建 messages 端点 URL /// /// - 已含 `/v1/messages` → 直接用 /// - 以 `/v1` 结尾 → 补 `/messages` /// - 否则(如 `.../api/anthropic`、`api.anthropic.com`)→ 补 `/v1/messages` fn messages_url(&self) -> String { let base = self.base_url.trim_end_matches('/'); if base.ends_with("/v1/messages") { return base.to_string(); } if base.ends_with("/v1") { return format!("{}/messages", base); } format!("{}/v1/messages", base) } /// 将统一 CompletionRequest 转换为 Anthropic 请求体 /// /// 转换要点: /// - system 消息从 messages 抽离到顶层 system 字段 /// - assistant 带 tool_calls → content 数组含 text + tool_use 块 /// - tool_result(role=Tool)→ user 消息含 tool_result 块;连续多个合并为一条 user /// - tool_definitions 的 parameters → input_schema fn convert_request(&self, req: CompletionRequest) -> AnthropicRequest { let model = if req.model.is_empty() { self.default_model.clone() } else { req.model }; // 抽离 system 消息 let system: Option = { let sys: Vec = req .messages .iter() .filter(|m| matches!(m.role, MessageRole::System)) .map(|m| m.content.clone()) .collect(); if sys.is_empty() { None } else { Some(sys.join("\n\n")) } }; // 构建非 system 消息(保留顺序,合并连续 tool_result) let mut messages: Vec = Vec::new(); let mut pending_tool_results: Vec = Vec::new(); for m in req.messages.iter() { match m.role { MessageRole::System => continue, MessageRole::Tool => { // 累积 tool_result 块,遇到非 Tool 消息时 flush。 // tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500 // 报 'ClaudeContentBlockToolResult' object has no attribute 'id', // 致会话卡死持续 500),跳过该块并告警。 match &m.tool_call_id { Some(tid) if !tid.is_empty() => { pending_tool_results.push(serde_json::json!({ "type": "tool_result", "tool_use_id": tid, "content": m.content, })); } _ => { warn!( content_preview = %m.content.chars().take(80).collect::(), "Anthropic tool_result 缺少 tool_use_id,已跳过该块(发 null 会触发 GLM 500)" ); } } } MessageRole::User => { Self::flush_tool_results(&mut messages, &mut pending_tool_results); messages.push(serde_json::json!({ "role": "user", "content": m.content })); } MessageRole::Assistant => { Self::flush_tool_results(&mut messages, &mut pending_tool_results); let mut content: Vec = Vec::new(); if !m.content.is_empty() { content.push(serde_json::json!({ "type": "text", "text": m.content })); } if let Some(calls) = &m.tool_calls { for tc in calls { let input: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null); content.push(serde_json::json!({ "type": "tool_use", "id": tc.id, "name": tc.function.name, "input": input, })); } } if content.is_empty() { content.push(serde_json::json!({ "type": "text", "text": "" })); } messages.push(serde_json::json!({ "role": "assistant", "content": content })); } } } Self::flush_tool_results(&mut messages, &mut pending_tool_results); let tools = req.tools.map(|defs| { defs.into_iter() .map(|d| AnthropicToolDef { name: d.function.name, description: Some(d.function.description).filter(|s| !s.is_empty()), input_schema: d.function.parameters, }) .collect() }); AnthropicRequest { model, messages, max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS), system, temperature: req.temperature, stream: req.stream, tools, tool_choice: req.tool_choice, } } /// 将累积的 tool_result 块作为一条 user 消息 flush 进消息列表 fn flush_tool_results( messages: &mut Vec, pending: &mut Vec, ) { if pending.is_empty() { return; } let blocks: Vec = pending.drain(..).collect(); messages.push(serde_json::json!({ "role": "user", "content": blocks })); } /// 统一鉴权头:x-api-key + anthropic-version fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { rb.header("x-api-key", &self.api_key) .header("anthropic-version", ANTHROPIC_VERSION) .header("Content-Type", "application/json") } } #[async_trait] impl LlmProvider for AnthropicCompatProvider { async fn complete(&self, request: CompletionRequest) -> anyhow::Result { let mut req = request; req.stream = false; let body = self.convert_request(req); debug!(model = %body.model, "Anthropic 同步调用"); // 指数退避重试(B-260616-07): 包裹 send + 状态码判定。 // 同时补 FR-R4 遗漏: Anthropic 同步路径此前无单请求 timeout(建连后挂起会无限 hang), // 此处加 60s timeout,与 OpenAI 路径对齐。 let label = format!("Anthropic[{}]", body.model); retry_with_backoff(&label, move |_| { let client = self.client.clone(); let url = self.messages_url(); let api_key = self.api_key.clone(); let body = body.clone(); async move { // send: 补 60s 单请求 timeout(此前缺失,FR-R4 遗漏) let rb = client .post(url) .header("x-api-key", &api_key) .header("anthropic-version", ANTHROPIC_VERSION) .header("Content-Type", "application/json") .timeout(Duration::from_secs(60)) .json(&body); let resp = match rb.send().await { Ok(r) => r, Err(e) => { if is_reqwest_error_retryable(&e) { return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e)); } return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e)); } }; if !resp.status().is_success() { let status = resp.status().as_u16(); let text = resp.text().await.unwrap_or_default(); let msg = format!("Anthropic API 错误 {}: {}", status, text); if is_status_retryable(status) { warn!(%status, "Anthropic 同步调用可重试状态码"); return AttemptOutcome::Retryable(msg); } error!(%status, %text, "Anthropic API 调用失败(不可重试)"); return AttemptOutcome::Fatal(msg); } let resp: AnthropicResponse = match resp.json().await { Ok(r) => r, Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)), }; // content 块中拼接 text,收集 tool_use let mut text = String::new(); let mut tool_calls: Vec = Vec::new(); for block in resp.content { match block.block_type.as_str() { "text" => { if let Some(t) = block.text { text.push_str(&t); } } "tool_use" => { let id = match block.id { Some(id) if !id.is_empty() => id, _ => { warn!( name = ?block.name, "Anthropic tool_use 块缺少 id,已跳过(空 id 会回传空 tool_use_id 触发 500)" ); continue; } }; let name = block.name.unwrap_or_default(); let args = block .input .map(|v| serde_json::to_string(&v).unwrap_or_default()) .unwrap_or_default(); tool_calls.push(ToolCall::new(id, name, args)); } other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"), } } let usage = TokenUsage { prompt_tokens: resp.usage.input_tokens, completion_tokens: resp.usage.output_tokens, total_tokens: resp.usage.input_tokens + resp.usage.output_tokens, }; AttemptOutcome::Ok(CompletionResponse { text, model: resp.model, usage, tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) }, }) } }) .await } async fn stream(&self, request: CompletionRequest) -> anyhow::Result { let mut req = request; req.stream = true; let body = self.convert_request(req); debug!(model = %body.model, "Anthropic 流式调用"); let resp = self .auth_headers(self.client.post(self.messages_url())) .json(&body) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); error!(%status, %text, "Anthropic 流式 API 调用失败"); anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text); } // 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk。 // 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。 // usage 累积:message_start 给 input_tokens,message_delta 给累计 output_tokens(非增量),message_stop 带出。 let mut usage_accum: Option = None; let stream = resp .bytes_stream() .eventsource() .map(move |event| match event { Ok(ev) => Ok(apply_anthropic_event(&ev.data, &mut usage_accum)), Err(e) => { // 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message, // 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...): // Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。 // 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。 let ctx = format!("Anthropic SSE 错误: {}", e); error!(error = %e, "Anthropic SSE 事件流错误"); Err(anyhow::Error::from(e).context(ctx)) } }); Ok(Box::pin(stream)) } fn name(&self) -> &str { "anthropic-compat" } fn endpoint(&self) -> String { self.messages_url() } } // ============================================================ // 单测(不发真实 HTTP,喂构造的 SSE data 字符串序列) // ============================================================ #[cfg(test)] mod tests { use super::*; /// 辅助:构造 message_start 事件 fn message_start(input_tokens: u32) -> String { format!( r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"output_tokens":0}}}}}}"#, input_tokens ) } /// 辅助:构造 message_delta 事件(output_tokens 为累计值) fn message_delta(output_tokens: u32) -> String { format!( r#"{{"type":"message_delta","delta":{{"stop_reason":"end_turn"}},"usage":{{"output_tokens":{}}}}}"#, output_tokens ) } /// 辅助:构造文本增量 content_block_delta fn text_delta(text: &str) -> String { format!( r#"{{"type":"content_block_delta","index":0,"delta":{{"type":"text_delta","text":"{}"}}}}"#, text ) } /// 辅助:构造 message_stop 事件 fn message_stop() -> &'static str { r#"{"type":"message_stop"}"# } /// 完整流:message_start 初始化 input + 多次 message_delta 累计覆盖 output + message_stop 带出 #[test] fn anthropic_full_stream_accumulates_usage() { let mut acc: Option = None; // 1) message_start:input=42,output=0 let c = apply_anthropic_event(&message_start(42), &mut acc); assert!(!c.finished); assert!(c.usage.is_none()); let a = acc.as_ref().expect("message_start 应初始化累加器"); assert_eq!(a.prompt_tokens, 42); assert_eq!(a.completion_tokens, 0); assert_eq!(a.total_tokens, 42); // 2) 文本增量不影响 usage let c = apply_anthropic_event(&text_delta("Hello"), &mut acc); assert_eq!(c.delta, "Hello"); assert!(!c.finished); assert_eq!(acc.as_ref().unwrap().completion_tokens, 0, "文本增量不应改 output"); // 3) message_delta:output_tokens=10(累计值,覆盖) let c = apply_anthropic_event(&message_delta(10), &mut acc); assert!(!c.finished); let a = acc.as_ref().unwrap(); assert_eq!(a.prompt_tokens, 42, "input 保持"); assert_eq!(a.completion_tokens, 10, "output 被覆盖为累计值"); assert_eq!(a.total_tokens, 52, "total 重算 = input+output"); // 4) 再次 message_delta:output_tokens=30(更大累计值,再覆盖) let _ = apply_anthropic_event(&message_delta(30), &mut acc); let a = acc.as_ref().unwrap(); assert_eq!(a.completion_tokens, 30, "后续累计值覆盖前值"); assert_eq!(a.total_tokens, 72); // 5) message_stop:带出累积 usage,finished=true,累加器清空 let c = apply_anthropic_event(message_stop(), &mut acc); assert!(c.finished); let u = c.usage.expect("message_stop 应带出累积 usage"); assert_eq!(u.prompt_tokens, 42); assert_eq!(u.completion_tokens, 30); assert_eq!(u.total_tokens, 72); assert!(acc.is_none(), "take() 后累加器应清空"); } /// message_delta 在没有 message_start 时也能补全累加器(get_or_insert 兜底) #[test] fn anthropic_message_delta_without_start_uses_default_input() { let mut acc: Option = None; let _ = apply_anthropic_event(&message_delta(15), &mut acc); let a = acc.as_ref().unwrap(); assert_eq!(a.prompt_tokens, 0, "无 message_start 时 input 兜底为 0"); assert_eq!(a.completion_tokens, 15); assert_eq!(a.total_tokens, 15); } /// message_delta 的 output_tokens 必须是累计覆盖(非累加):连续两个 delta 5 和 8,结果应是 8 不是 13 #[test] fn anthropic_message_delta_output_is_cumulative_not_incremental() { let mut acc: Option = None; apply_anthropic_event(&message_start(100), &mut acc); apply_anthropic_event(&message_delta(5), &mut acc); apply_anthropic_event(&message_delta(8), &mut acc); let c = apply_anthropic_event(message_stop(), &mut acc); let u = c.usage.unwrap(); assert_eq!(u.completion_tokens, 8, "output_tokens 是累计值,覆盖而非累加"); assert_eq!(u.total_tokens, 108); } /// 无 usage 字段的流:message_stop 时 usage 为 None #[test] fn anthropic_message_stop_without_any_usage() { let mut acc: Option = None; let _ = apply_anthropic_event(&text_delta("hi"), &mut acc); assert!(acc.is_none(), "文本增量不初始化累加器"); let c = apply_anthropic_event(message_stop(), &mut acc); assert!(c.finished); assert!(c.usage.is_none(), "无 usage 时 message_stop usage 为 None"); } /// content_block_start (tool_use) 带 id+name #[test] fn anthropic_content_block_start_tool_use() { let mut acc: Option = None; let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool_1","name":"get_weather"}}"#; let c = apply_anthropic_event(data, &mut acc); assert!(acc.is_none(), "content_block_start 不动 usage"); let tcs = c.tool_calls.expect("应有 tool_calls"); assert_eq!(tcs.len(), 1); assert_eq!(tcs[0].index, 1); assert_eq!(tcs[0].id.as_deref(), Some("tool_1")); assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather")); assert!(tcs[0].function_arguments.is_none()); assert!(!c.finished); } /// content_block_delta (input_json_delta) → 工具入参增量 #[test] fn anthropic_content_block_delta_input_json() { let mut acc: Option = None; let data = r#"{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}"#; let c = apply_anthropic_event(data, &mut acc); let tcs = c.tool_calls.expect("应有 tool_calls 增量"); assert_eq!(tcs[0].index, 2); assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":")); assert!(tcs[0].id.is_none()); assert_eq!(c.delta, ""); assert!(!c.finished); } /// error 事件 → error=Some + finished=false(R-P1-1:避免残缺响应被当正常完成入库, /// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致) #[test] fn anthropic_error_event_yields_error_not_finished() { let mut acc: Option = None; apply_anthropic_event(&message_start(10), &mut acc); let c = apply_anthropic_event(r#"{"type":"error","error":{"message":"overloaded"}}"#, &mut acc); assert!(!c.finished, "error 不走 finished 完成路径,否则残缺响应会被当正常完成"); assert_eq!(c.error.as_deref(), Some("overloaded"), "error 事件应携带错误消息"); assert!(c.usage.is_none(), "error 不带出 usage"); assert!(c.delta.is_empty(), "error 不带文本增量"); assert!(acc.is_some(), "error 不应清空已累积的 usage(与原实现一致)"); } /// error 事件无 error.message 字段时兜底为 "stream error" #[test] fn anthropic_error_event_missing_message_falls_back() { let mut acc: Option = None; let c = apply_anthropic_event(r#"{"type":"error"}"#, &mut acc); assert_eq!(c.error.as_deref(), Some("stream error")); assert!(!c.finished); } /// ping / content_block_stop 等事件 → 空且不 finished #[test] fn anthropic_ping_and_block_stop_yield_empty_chunk() { let mut acc: Option = None; let c = apply_anthropic_event(r#"{"type":"ping"}"#, &mut acc); assert!(!c.finished); assert_eq!(c.delta, ""); assert!(acc.is_none()); let c = apply_anthropic_event(r#"{"type":"content_block_stop","index":0}"#, &mut acc); assert!(!c.finished); assert_eq!(c.delta, ""); } /// 非法 JSON → 空 chunk,不 panic #[test] fn anthropic_malformed_json_yields_empty_chunk() { let mut acc: Option = None; let c = apply_anthropic_event("not json", &mut acc); assert!(!c.finished); assert_eq!(c.delta, ""); assert!(acc.is_none()); } }