Files
DevFlow/crates/df-ai/src/anthropic_compat.rs
T
lxy 57d6a2d066 修复: AI 对话/工具可靠性(sanitize 三元组 + G2 签名重复 + handshake 不杀 loop + 空 tool_call id 兜底)
治 5 个对话停止/工具失败根因:sanitize 三元组按 id 配对治 400;G2 探索熔断从结果空
改签名重复判定(治误停正常探索);handshake 删越权强杀活 loop(generating 归 guard 单源);
空 tool_call id 兜底 gen_<index>(治 SenseNova 工具结果路由错位)。
2026-08-02 02:21:30 +08:00

1126 lines
52 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.
//! Anthropic 兼容 Provider — 通过 /v1/messages 端点实现
//!
//! 覆盖: Claude 官方 / GLM 订阅端点 (open.bigmodel.cn/api/anthropic) / 任意 Messages API 网关
//! 支持: 同步调用 + SSE 流式 + Tool Use
//!
//! 与 OpenAI 协议的关键差异由本模块内部完成转换,对外仍暴露统一的 LlmProvider trait
//! 上层 (Agentic Loop / AiNode) 无需感知协议。
//!
//! 协议数据结构(请求/响应 struct)与 SSE 事件解析纯函数已抽至 `anthropic_helpers`
//! 本模块仅保留 Provider struct + implHTTP 调用),Rust impl 块不可跨文件故作此切分。
use async_trait::async_trait;
use futures::StreamExt;
use reqwest::Client;
use std::time::Duration;
use tracing::{debug, error, warn};
use crate::provider::{
tool_call_id_or_fallback, CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
StreamResult, TokenUsage, ToolCall,
};
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
#[cfg(test)]
use crate::provider::ChatMessage;
use crate::retry::{
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
};
// 协议数据结构 + SSE 纯解析(apply_anthropic_event / AnthropicRequest 等)抽至 anthropic_helpers
// 此处 use 引入以保持本模块内引用路径不变(零行为变更搬迁)。
use crate::anthropic_helpers::{
apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef,
ANTHROPIC_VERSION, DEFAULT_MAX_TOKENS,
};
// ============================================================
// Provider 实现
// ============================================================
/// Anthropic 兼容 LLM Provider
pub struct AnthropicCompatProvider {
client: Client,
api_key: String,
base_url: String,
default_model: String,
}
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<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 OpenAI 共用 DRY)。
let client = crate::build_provider_client();
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_resultrole=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<String> = {
let sys: Vec<String> = 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<serde_json::Value> = Vec::new();
let mut pending_tool_results: Vec<serde_json::Value> = Vec::new();
for m in req.messages.iter() {
match m.role {
MessageRole::System => continue,
MessageRole::Tool => {
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
// tool_use_id 为 None 时绝不能发 nullGLM 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::<String>(),
"Anthropic tool_result 缺少 tool_use_id,已跳过该块(发 null 会触发 GLM 500"
);
}
}
}
MessageRole::User => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
// 多模态 user 消息 → content blocks 数组(text/image)。
// 含图时把 content + parts 拍平成 blocksText 片 → {type:text}
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
// Anthropic 协议要求 image 必须内嵌 base64(不接受 URL 直传)。
// 现状:前端只产 base64 模式图片片,url 模式当前不可达。
// 未来若加 url 图片输入,必须在 commands 层补 url→base64 预拉
//provider 不发额外 HTTP),否则下方兜底会发空 data 致 Anthropic 400。
// 纯文本消息(无图)保持原字符串简写,与现有端点零回归。
if m.has_image() {
let blocks: Vec<serde_json::Value> = m
.flattened_parts()
.into_iter()
.map(Self::content_part_to_block)
.collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
} else {
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<serde_json::Value> = 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 {
// arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
// → 空 object 兜底。Anthropic/GLM 要求 tool_use.input 必为 object,
// null 直触发 1214「messages 参数非法」。
let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
.unwrap_or_else(|_| serde_json::json!({}));
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);
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
// tool_result 转成 user 后紧跟 push 新 user → 连续两 user。合并成一条 user 含
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
Self::merge_consecutive_users(&mut messages);
// 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
// "首条 role=assistant 非法"。
// 根本解用"补"而非"砍":开头补一条 user 占位,保留全部上下文(assistant 的 tool_use
// 与其后 user 的 tool_result 配对完整),precheck 必过。砍会丢工具调用历史,且多轮
// [asst(tu),user(tr),asst(tu),user(tr),...] 逐对砍到空。占位是异常路径轻量噪声
// (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages);
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 非 object(未来误用)→ 兜底 {"type":"object"},
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
input_schema: if d.function.parameters.is_object() {
d.function.parameters
} else {
serde_json::json!({"type": "object"})
},
})
.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,
}
}
/// 单个 ContentPart → Anthropic content blocktext/image)。
/// - Text 片 → {type:text, text}
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
let mt = media_type.unwrap_or_else(|| "image/png".into());
let data = base64.unwrap_or_else(|| {
// 完整性兜底:当前 url 模式不可达(前端只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400warn 留痕但不阻塞(避免静默吞数据)。
if url.is_some() {
warn!(
url = ?url,
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 datacommands 层未补 url→base64 预拉)"
);
}
String::new()
});
serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": mt,
"data": data,
}
})
}
}
}
/// 将累积的 tool_result 块作为一条 user 消息 flush 进消息列表
fn flush_tool_results(
messages: &mut Vec<serde_json::Value>,
pending: &mut Vec<serde_json::Value>,
) {
if pending.is_empty() {
return;
}
let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
}
/// 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
/// 前一轮以 tool_result 结尾)。合并成一条 user 含 [tool_result..., text] blocks,合法。
/// content 形态:string(简写)或 array(blocks),统一规范化为 array 后拼接。
fn merge_consecutive_users(messages: &mut Vec<serde_json::Value>) {
let is_user = |m: &serde_json::Value| m.get("role").and_then(|r| r.as_str()) == Some("user");
let mut i = 0;
while i + 1 < messages.len() {
if is_user(&messages[i]) && is_user(&messages[i + 1]) {
let next_content = messages[i + 1].get("content").cloned().unwrap_or(serde_json::Value::Null);
let cur = &mut messages[i];
// 当前 user content 规范化为 array
let mut blocks: Vec<serde_json::Value> = match cur.get("content").cloned() {
Some(serde_json::Value::String(s)) => vec![serde_json::json!({ "type": "text", "text": s })],
Some(serde_json::Value::Array(a)) => a,
_ => vec![],
};
// 并入下一个 user content
match next_content {
serde_json::Value::String(s) => blocks.push(serde_json::json!({ "type": "text", "text": s })),
serde_json::Value::Array(a) => blocks.extend(a),
_ => {}
}
cur["content"] = serde_json::Value::Array(blocks);
messages.remove(i + 1);
// 不增 i:继续合并 i 与新 i+1(可能多个连续 user)
} else {
i += 1;
}
}
}
/// 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
///
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
/// 或从历史片段截取时,真正的首条 user 已被裁剪/压缩掉。直接发触发 precheck "首条
/// role=assistant 非法"。
///
/// **用"补"而非"砍"**:在开头插一条 user 占位消息。
/// - 砍掉首条 assistant 会丢失有效上下文(其 tool_use 与后续 user 的 tool_result 是完整
/// 配对),且多轮 [asst(tu),user(tr),asst(tu),user(tr),...] 会被逐对砍到空;
/// - 补占位则全部上下文保留(占位 user 紧贴原首条 assistant,不破坏 user/assistant 交替),
/// tool_use/tool_result 配对完整不动,**无 orphan 产生**(故砍策略那套 orphan 清理在此不需要),
/// 占位 content 非空过 precheck 的"user content 空"校验。
///
/// 仅异常路径触发(正常会话首条本就是 user),占位文案是轻量噪声,远优于丢弃工具调用历史。
fn ensure_leading_user(messages: &mut Vec<serde_json::Value>) {
let first_role = messages
.first()
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
.unwrap_or("");
if first_role == "user" {
return;
}
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
// Anthropic 协议要求 messages 至少一条且首条 user,补占位让降级会话能继续(不丢这条兜底)。
warn!(
first_role,
msg_count = messages.len(),
"ensure_leading_user: 首条非 user(含空),补 user 占位(防 Anthropic 首条 assistant/空 messages 非法)"
);
messages.insert(0, serde_json::json!({
"role": "user",
"content": "(continued from previous context)",
}));
}
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
/// 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
fn summarize_messages(messages: &[serde_json::Value]) -> String {
let lines: Vec<String> = messages
.iter()
.enumerate()
.map(|(i, m)| {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
let desc = match m.get("content") {
Some(serde_json::Value::String(s)) => format!("text({}B)", s.len()),
Some(serde_json::Value::Array(blocks)) => {
let parts: Vec<String> = blocks
.iter()
.map(|b| {
let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or("?");
match ty {
"text" => format!(
"text({}B)",
b.get("text").and_then(|t| t.as_str()).map(|s| s.len()).unwrap_or(0)
),
"tool_use" => format!(
"tool_use[id={},input_obj={}]",
b.get("id").and_then(|t| t.as_str()).unwrap_or(""),
b.get("input").map(|v| v.is_object()).unwrap_or(false)
),
"tool_result" => format!(
"tool_result[tid={}]",
b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("")
),
"image" => "image".to_string(),
_ => ty.to_string(),
}
})
.collect();
format!("[{}]", parts.join(","))
}
_ => "?".to_string(),
};
format!("#{}:{} {}", i, role, desc)
})
.collect();
format!("{} msgs: {}", lines.len(), lines.join(" | "))
}
/// 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
/// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result
/// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。
fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> {
if messages.is_empty() {
return Err("messages 为空".into());
}
let first_role = messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
if first_role != "user" {
return Err(format!("首条 role={} 非法(须 user)", first_role));
}
for w in messages.windows(2) {
let r0 = w[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
let r1 = w[1].get("role").and_then(|r| r.as_str()).unwrap_or("");
if r0 == r1 && (r0 == "user" || r0 == "assistant") {
return Err(format!("连续同 role={}", r0));
}
}
let mut tool_use_ids: Vec<&str> = Vec::new();
for (i, m) in messages.iter().enumerate() {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("");
match m.get("content") {
Some(serde_json::Value::String(s)) if s.is_empty() && role == "user" => {
return Err(format!("#{} user content 空", i));
}
Some(serde_json::Value::Array(blocks)) => {
if blocks.is_empty() && role == "user" {
return Err(format!("#{} user content 空数组", i));
}
for b in blocks {
Self::check_block(b, i, &mut tool_use_ids)?;
}
}
_ => {}
}
}
Ok(())
}
/// 校验单个 content blockprecheck_messages 内部用)。
/// - tool_use: 收集 id,校验 input 为 object
/// - tool_result: 校验 tool_use_id 有前置 tool_use(非 orphan
fn check_block<'a>(
b: &'a serde_json::Value,
idx: usize,
tool_use_ids: &mut Vec<&'a str>,
) -> Result<(), String> {
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"tool_use" => {
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
tool_use_ids.push(id);
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
return Err(format!("#{} tool_use input 非 object", idx));
}
}
"tool_result" => {
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
return Err(format!(
"#{} orphan tool_result(tid={} 无前置 tool_use)",
idx, tid
));
}
}
_ => {}
}
Ok(())
}
/// 统一鉴权头: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<CompletionResponse> {
let mut req = request;
req.stream = false;
let body = self.convert_request(req);
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
}
debug!(model = %body.model, "Anthropic 同步调用");
// 指数退避重试: 包裹 send + 状态码判定。
// 同时补单请求 timeout(Anthropic 同步路径无 timeout 会 hang),此处加 60s,与 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))
.version(reqwest::Version::HTTP_11)
.json(&body);
let resp = match rb.send().await {
Ok(r) => r,
Err(e) => {
// 记 reqwest 错误源因链(is_*/source)。原仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = ?e.url(),
source = ?std::error::Error::source(&e),
"Anthropic 同步 send 失败"
);
if is_reqwest_error_retryable(&e) {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!(
"请求失败(不可重试): {} [timeout={} connect={} body={} src={:?}]",
e,
e.is_timeout(),
e.is_connect(),
e.is_body(),
std::error::Error::source(&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<ToolCall> = Vec::new();
// CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。
// 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。
let mut tool_use_idx: usize = 0;
for block in resp.content {
match block.block_type.as_str() {
"text" => {
if let Some(t) = block.text {
text.push_str(&t);
}
}
"tool_use" => {
// CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。
// 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallbackDRY 共用
// tool_call_id_or_fallback)。Anthropic 一般非空,此为兼容缺陷兜底。
// 不再 warn+continuecontinue 会丢工具调用致 LLM 拿不到结果)。
let raw_id = block.id.unwrap_or_default();
let id = tool_call_id_or_fallback(&raw_id, tool_use_idx, "gen_anthropic");
if raw_id.is_empty() {
warn!(
fallback_id = %id,
name = ?block.name,
"Anthropic tool_use 块 id 为空,已生成 fallback id(原 continue 跳过会丢工具调用)"
);
}
tool_use_idx += 1;
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) },
reasoning_content: None,
})
}
})
.await
}
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
let mut req = request;
req.stream = true;
let body = self.convert_request(req);
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
}
debug!(model = %body.model, "Anthropic 流式调用");
// send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
// 流式长生成任务会被误砍。改用 tokio::time::timeout
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
let send_future = self
.auth_headers(self.client.post(self.messages_url()))
.json(&body)
.version(reqwest::Version::HTTP_11)
.send();
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = %self.messages_url(),
source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败"
);
anyhow::bail!(
"send 失败: {} [timeout={} connect={} body={} request={} src={:?}]",
e,
e.is_timeout(),
e.is_connect(),
e.is_body(),
e.is_request(),
std::error::Error::source(&e)
);
}
Err(_elapsed) => {
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
tracing::error!(
url = %self.messages_url(),
"Anthropic 流式 send 超时(60s 未返回响应头)"
);
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
}
};
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);
}
// 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
let mut usage_accum: Option<TokenUsage> = None;
// MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
let messages_summary = Self::summarize_messages(&body.messages);
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
match result {
Ok(events) => {
for data in events {
let mut chunk = apply_anthropic_event(&data, &mut usage_accum);
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
}
chunks.push(Ok(chunk));
}
}
Err(e) => {
let ctx = format!("Anthropic SSE 错误: {}", e);
error!("{}", ctx);
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
}
}
futures::stream::iter(chunks)
});
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::*;
// apply_anthropic_event / TokenUsage 经 super::*(含 anthropic_helpers::apply_anthropic_event 的 use)可见。
/// 辅助:构造 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<TokenUsage> = None;
// 1) message_startinput=42output=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_deltaoutput_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_deltaoutput_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:带出累积 usagefinished=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<TokenUsage> = 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<TokenUsage> = 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<TokenUsage> = 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<TokenUsage> = 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<TokenUsage> = 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=falseR-P1-1:避免残缺响应被当正常完成入库,
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
#[test]
fn anthropic_error_event_yields_error_not_finished() {
let mut acc: Option<TokenUsage> = 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<TokenUsage> = 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<TokenUsage> = 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<TokenUsage> = None;
let c = apply_anthropic_event("not json", &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none());
}
// ---------- 多模态 convert_request ----------
/// 含图 user 消息 → content blockstext + image source.base64
#[test]
fn anthropic_convert_multimodal_user_blocks() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![ChatMessage::user_parts(
"看图",
vec![crate::provider::ContentPart::image_base64("image/png", "iVBOR")],
)],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
// user 消息 content 应为数组形态
let user_msg = body
.messages
.iter()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
.expect("应有 user 消息");
let content = user_msg.get("content").and_then(|c| c.as_array()).expect("user content 应为数组");
// 顺序:content "看图" → text 块,image 片 → image 块
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "看图");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["media_type"], "image/png");
assert_eq!(content[1]["source"]["data"], "iVBOR");
}
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
#[test]
fn anthropic_convert_text_only_user_remains_string() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![ChatMessage::user("hello")],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
let user_msg = body
.messages
.iter()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
.expect("应有 user 消息");
// 纯文本 → 字符串简写(非数组)
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
}
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
ChatMessage::assistant_with_tools(
text,
vec![ToolCall::new(tool_id, tool_name, "{}")],
)
}
/// 精确复现线上场景——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
#[test]
fn anthropic_ensure_leading_user_tool_use_chain_preserves_context() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
msg_assistant_with_tool_use("我来帮你", "call_f892", "read_file"),
ChatMessage::tool_result("call_f892", "file content"),
msg_assistant_with_tool_use("继续", "call_003a", "write_file"),
ChatMessage::tool_result("call_003a", "done"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
// 首条必须是 user(补的占位)
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
assert_eq!(first_role, "user", "首条应为 user(补占位)");
// 上下文零丢失:占位 user + asst(tu) + user(tr) + asst(tu) + user(tr) = 5 条
assert_eq!(
body.messages.len(), 5,
"应保留全部上下文(占位 + 原 4 条),实际 {} 条", body.messages.len()
);
assert!(
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
"precheck 应通过,实际 messages: {}",
AnthropicCompatProvider::summarize_messages(&body.messages)
);
}
/// 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
#[test]
fn anthropic_ensure_leading_user_plain_assistant() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
ChatMessage::assistant("你好"),
ChatMessage::user("请帮我"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
assert_eq!(body.messages.len(), 3, "占位 + 原 2 条");
assert_eq!(
body.messages[0].get("role").and_then(|r| r.as_str()),
Some("user"),
);
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// 正常序列(user 开头)不补占位——零回归验证。
#[test]
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi there"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
assert_eq!(body.messages.len(), 2, "正常序列不应补占位");
assert_eq!(
body.messages[0].get("role").and_then(|r| r.as_str()),
Some("user"),
);
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
#[test]
fn anthropic_ensure_leading_user_three_round_chain() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
msg_assistant_with_tool_use("a1", "call_1", "read_file"),
ChatMessage::tool_result("call_1", "r1"),
msg_assistant_with_tool_use("a2", "call_2", "write_file"),
ChatMessage::tool_result("call_2", "r2"),
msg_assistant_with_tool_use("a3", "call_3", "list_directory"),
ChatMessage::tool_result("call_3", "r3"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
assert_eq!(first_role, "user", "首条应为 user(补占位)");
// 占位 + 3×[asst(tu),user(tr)] = 7 条,全部保留
assert_eq!(
body.messages.len(), 7,
"3 轮链应全保留(占位 + 原 6 条),实际 {} 条", body.messages.len()
);
assert!(
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
"3 轮链补占位后 precheck 应通过,实际: {}",
AnthropicCompatProvider::summarize_messages(&body.messages)
);
}
/// 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
#[test]
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
assert_eq!(body.messages.len(), 1, "空 messages 应补 1 条 user 占位");
assert_eq!(
body.messages[0].get("role").and_then(|r| r.as_str()),
Some("user"),
);
assert!(
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
"补占位后 precheck 应通过"
);
}
}