Files
DevFlow/crates/df-ai/src/anthropic_compat.rs
T
lxy 18e50c5af7 新增: 媒体引用化存储与消息溯源
- media_store 模块: sha256 去重落盘 + 阿里云 OSS 双写(签名手写), OSS 失败降级本机引用
- 飞书图片入站: image 消息下载装配 vision 片, 3 次退避+120s 超时+残件清理防线
- 出站回传: 报告 [交付] 路径行自动上传飞书原生附件, 凭据/密钥名拦截兜底
- 溯源 V52: ai_messages.source 列, 飞书 msg_id/chat_id/发送者随消息落库
- spawn offload: 桌面粘贴图 base64 统一转落盘引用, SQLite 不进大字符串
- 引用统一 url 字段(https/file://)不自造 scheme, image_ref 发送前按需展开
2026-10-11 07:33:18 +08:00

1523 lines
74 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 + impl(HTTP 调用),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, SystemBlock,
ANTHROPIC_VERSION, DEFAULT_MAX_TOKENS,
};
// ============================================================
// Anthropic prompt caching 开关(进程级全局,受 AppState settings 同步)
// ============================================================
/// Anthropic prompt caching 总开关(默认关)。
///
/// 该分支同时服务 Claude 官方 / GLM 订阅端点 (open.bigmodel.cn/api/anthropic) / 任意 Messages
/// API 网关——**cache_control 字段可能被非官方网关拒收**,故必须默认关、用户对支持的端点显式开启。
///
/// 开关开:`convert_request` 把 system 包成 blocks 数组并附加 `cache_control:{type:ephemeral}`
/// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存),
/// 并在历史段稳定边界补消息级断点(使 system 之后的对话历史也可命中)。
/// 开关关:system 保持纯字符串、消息不打断点(现状行为,零风险兼容网关)。
///
/// 进程级全局而非 per-provider:上层(AppState settings)按「用户对当前启用的 anthropic 端点
/// 是否支持缓存」一次性开启,所有 anthropic 请求一致生效。默认关。
static ANTHROPIC_CACHE_ENABLED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// 设置 anthropic prompt caching 开关(上层读取 AppState settings 后同步)。
pub fn set_anthropic_cache_enabled(enabled: bool) {
ANTHROPIC_CACHE_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
/// 读取 anthropic prompt caching 开关(`convert_request` 组装 system 形态用)。
pub fn anthropic_cache_enabled() -> bool {
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
// ============================================================
// 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 {
// 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_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 消息。
// 开关开 → blocks 数组 + cache_control(Anthropic prompt caching,让 system 稳定段在
// 支持缓存的端点上命中);开关关 → 纯字符串(兼容非官方网关,零风险)。
let system: Option<SystemBlock> = {
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 if anthropic_cache_enabled() {
Some(SystemBlock::Cached(vec![serde_json::json!({
"type": "text",
"text": sys.join("\n\n"),
"cache_control": { "type": "ephemeral" },
})]))
} else {
Some(SystemBlock::Plain(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 时绝不能发 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::<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 拍平成 blocks:Text 片 → {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);
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control,
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。
if anthropic_cache_enabled() {
Self::apply_message_cache_breakpoint(&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()
});
// 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。
// 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。
let temperature = if req.temperature.is_some()
&& !crate::model_probe::probe(&model).temperature_supported
{
tracing::debug!(model = %model, "模型不支持 temperature,已省略该参数");
None
} else {
req.temperature
};
AnthropicRequest {
model,
messages,
max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
system,
temperature,
stream: req.stream,
tools,
tool_choice: req.tool_choice,
}
}
/// 单个 ContentPart → Anthropic content block(text/image)。
/// - Text 片 → {type:text, text}
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
/// base64 内嵌;本地 file:// 引用(#958)先展开为 base64,http(s) url 模式
/// 当前不可达,兜底发空 data + warn(保留原行为)。
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
let p = crate::image_ref::inline_local_image_part(p);
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 400,warn 留痕但不阻塞(避免静默吞数据)。
if url.is_some() {
warn!(
url = ?url,
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 data(commands 层未补 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 }));
}
/// 消息级缓存断点:在「历史段稳定边界」消息的末个 content block 上打 cache_control。
///
/// 断点落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
///
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) {
let n = messages.len();
if n == 0 {
return;
}
let last_is_user =
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
let idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
let msg = &mut messages[idx];
let cache_control = serde_json::json!({ "type": "ephemeral" });
match msg.get("content").cloned() {
Some(serde_json::Value::Array(mut blocks)) => {
if let Some(last_block) = blocks.last_mut() {
last_block["cache_control"] = cache_control;
}
msg["content"] = serde_json::Value::Array(blocks);
}
Some(serde_json::Value::String(text)) => {
msg["content"] = serde_json::json!([{
"type": "text",
"text": text,
"cache_control": cache_control,
}]);
}
_ => {}
}
}
/// 合并相邻 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" {
// 首条 user 但 content 为空串(会话压缩/续发残留,典型 text(0B)):原样发出会被
// precheck "user content 空" 拦下而整请求失败。与上方补占位同思路——就地填
// 占位文案(空串无信息可丢),上游实测接受非空占位,precheck 亦放行。
let empty_str = messages[0]
.get("content")
.and_then(|c| c.as_str())
.map(|s| s.is_empty())
.unwrap_or(false);
if empty_str {
warn!(
msg_count = messages.len(),
"ensure_leading_user: 首条 user content 为空串,填占位(防 precheck 拦截)"
);
messages[0]["content"] =
serde_json::json!("(continued from previous context)");
}
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 block(precheck_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 失败"
);
// is_decode 补可重试:body 中途断连在 reqwest 归 decode 类,complete 重发
// 幂等安全(受 3 次/30s 预算约束,真正格式错重试同败,预算内止损)。
if is_reqwest_error_retryable(&e) || e.is_decode() {
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);
}
// body 解析:text 先取再 serde 分层判定:读取中断(text Err,reqwest 归
// is_decode 类,complete 重发幂等安全)可重试;JSON 结构错属 Fatal(响应已
// 完整送达,重试同败)。Response::json 把 serde 错同包成 is_decode,先 text
// 后 from_str 才能区分二者,避免确定性结构错被重试白烧预算。
let text = match resp.text().await {
Ok(t) => t,
Err(e) => {
if is_reqwest_error_retryable(&e) || e.is_decode() {
return AttemptOutcome::Retryable(format!("响应读取失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("响应读取失败(不可重试): {}", e));
}
};
let resp: AnthropicResponse = match serde_json::from_str(&text) {
Ok(r) => r,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
// content 块中拼接 text,收集 tool_use,累积 thinking
let mut text = String::new();
let mut reasoning = 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);
}
}
"thinking" => {
// thinking 块正文回填 reasoning_content 通道(与 DeepSeek 同路入库展示)。
// 仅入库与展示,绝不回传 anthropic 历史(丢弃 signature 的伪造回传必 400)。
if let Some(t) = block.thinking {
reasoning.push_str(&t);
}
}
// redacted_thinking 为端点侧密文块,不可解析也不可伪造回传,合规丢弃不告警
"redacted_thinking" => {}
"tool_use" => {
// CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。
// 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用
// tool_call_id_or_fallback)。Anthropic 一般非空,此为兼容缺陷兜底。
// 不再 warn+continue(continue 会丢工具调用致 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,
// anthropic cache 字段:creation(写入)= miss 全价语义,read(命中)= hit 低价
prompt_cache_hit_tokens: resp.usage.cache_read_input_tokens,
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
reasoning_tokens: 0,
};
AttemptOutcome::Ok(CompletionResponse {
text,
model: resp.model,
usage,
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
// 无 thinking 块时保持 None(语义与 DeepSeek 路径一致,空串不落库)
reasoning_content: if reasoning.is_empty() { None } else { Some(reasoning) },
})
}
})
.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 足够宽容。
// send 阶段弱网退避: connect 类失败(隧道断网/基站切换/DNS 抖动)请求未发出, 重试零副作用。
// 网络感知分速(net_probe 基准面): 本机断网进长等待按自适应间隔探测恢复(隧道级分钟级波动),
// 单点故障指数短退避 1s→8s; 10 分钟窗口耗尽才放弃。timeout/decode/HTTP 状态错不重试。
// 与 openai_compat 同构。
let resp = {
let start = std::time::Instant::now();
let mut offline_logged = false;
let mut offline_polls = 0usize;
let mut attempt = 0usize;
loop {
attempt += 1;
let send_future = self
.auth_headers(self.client.post(self.messages_url()))
.json(&body)
.version(reqwest::Version::HTTP_11)
.send();
let outcome = tokio::time::timeout(Duration::from_secs(60), send_future).await;
match outcome {
Ok(Ok(r)) => break r,
Ok(Err(e)) => {
// 谓词结构化分类(对齐 openai 路径,共用 crate::send_error_class)。原 bail 串
// [timeout={} connect={}=...] 的字面词让一切错误(含 builder 错)侥幸可重试。
// 类词改由谓词决定置串首;谓词转储缩写为 t/c/b/r 防误触分类词表,source 链
// 保留真实传输词(reset/broken pipe 等)作串兜底。
let preds = crate::SendErrPreds::of(&e);
let retryable = preds.is_connect || (preds.is_request && !preds.is_builder);
if retryable && start.elapsed() < crate::net_probe::SEND_RETRY_WINDOW {
// 本机断网: 等基准面恢复(自适应间隔), 不消耗退避档位
if !crate::net_probe::baseline_alive().await {
offline_polls += 1;
let wait = crate::net_probe::offline_probe_wait(offline_polls);
if !offline_logged {
warn!(
url = %self.messages_url(),
window_secs = crate::net_probe::SEND_RETRY_WINDOW.as_secs(),
"本机网络不可达(疑似隧道/电梯弱网), 按自适应间隔探测恢复, 恢复后立即重试"
);
offline_logged = true;
}
tokio::time::sleep(wait).await;
continue;
}
offline_polls = 0;
offline_logged = false;
let delay = Duration::from_millis(
1000u64.saturating_mul(1 << (attempt - 1).min(3)).min(8000),
);
warn!(
url = %self.messages_url(),
attempt,
delay_ms = delay.as_millis() as u64,
error = %e,
"流式 send 连接失败, 退避重试"
);
tokio::time::sleep(delay).await;
continue;
}
let class = crate::send_error_class(preds);
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
class = class,
url = %self.messages_url(),
source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败"
);
anyhow::bail!(
"send 失败({}): {} [t={} c={} b={} r={} src={:?}]",
class,
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)可见。
/// convert_request 读取进程级缓存开关(全局 AtomicBool)。开关切换用例的「开」窗口内,
/// 并行运行的普通转换用例会带上缓存形态(system blocks / 消息级断点)致断言互踩。
/// 所有走 convert_request 的用例统一先取本锁串行化,消除进程级状态的用例间污染。
static CONVERT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn convert_lock() -> std::sync::MutexGuard<'static, ()> {
CONVERT_LOCK.lock().unwrap_or_else(|p| p.into_inner())
}
/// 辅助:构造 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_start:input=42,output=0
// (6bafdcd 起 usage 同帧挂载:message_start 即带初始快照,中途断连也能拿到真实 usage)
let c = apply_anthropic_event(&message_start(42), &mut acc);
assert!(!c.finished);
let u0 = c.usage.expect("message_start 应同帧带出初始 usage 快照");
assert_eq!(u0.prompt_tokens, 42);
assert_eq!(u0.completion_tokens, 0);
assert_eq!(u0.total_tokens, 42);
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<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);
}
/// stop_reason=max_tokens(message_delta 帧携带)必须置截断标志;end_turn 不置。
/// 终态 finished 在后续 message_stop 帧,消费方跨帧 OR 累积标志。
#[test]
fn anthropic_max_tokens_stop_reason_sets_truncation_flag() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":9}}"#;
let c = apply_anthropic_event(data, &mut acc);
assert!(!c.finished, "message_delta 非终态帧");
assert!(c.truncated_by_max_tokens, "max_tokens 必须置截断标志");
let mut acc2: Option<TokenUsage> = None;
let c2 = apply_anthropic_event(&message_delta(5), &mut acc2);
assert!(!c2.truncated_by_max_tokens, "end_turn 不置截断标志");
}
/// 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);
}
/// thinking_delta → reasoning_content 增量(不混入正文 delta);signature_delta 安静忽略。
/// 推理正文走 reasoning_content 通道与 DeepSeek 同路入库展示。
#[test]
fn anthropic_thinking_delta_accumulates_reasoning() {
let mut acc: Option<TokenUsage> = None;
let think1 = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"先查"}}"#;
let c1 = apply_anthropic_event(think1, &mut acc);
assert_eq!(c1.reasoning_content.as_deref(), Some("先查"), "thinking_delta 应产出 reasoning_content");
assert_eq!(c1.delta, "", "思考增量不得混入正文 delta");
assert!(!c1.finished);
let think2 = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"用户意图"}}"#;
let c2 = apply_anthropic_event(think2, &mut acc);
assert_eq!(c2.reasoning_content.as_deref(), Some("用户意图"));
// signature_delta:thinking 块签名字段,本侧无处消费,安静跳过(空 chunk 不告警)
let sig = r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-abc"}}"#;
let c3 = apply_anthropic_event(sig, &mut acc);
assert!(c3.reasoning_content.is_none());
assert!(c3.delta.is_empty());
assert!(!c3.finished);
// 思考块之后正文照常
let c4 = apply_anthropic_event(&text_delta("答案"), &mut acc);
assert_eq!(c4.delta, "答案");
assert!(c4.reasoning_content.is_none());
}
/// error 事件 → error=Some + finished=false避免残缺响应被当正常完成入库,
/// 由 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 blocks(text + image source.base64)
#[test]
fn anthropic_convert_multimodal_user_blocks() {
let _convert = convert_lock();
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");
}
/// 本地 file:// 引用(#958)在转换层展开为 base64 source;缺失降级 text 块不炸请求
#[test]
fn anthropic_convert_expands_local_file_ref() {
use base64::Engine as _;
let _convert = convert_lock();
let dir = std::env::temp_dir().join("df-ai-anthropic-ref-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ref.png");
std::fs::write(&path, b"\x89PNG ok").unwrap();
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 {
url: Some(format!("file://{}", path.display())),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}],
)],
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 消息");
let content = user_msg.get("content").and_then(|c| c.as_array()).expect("user content 应为数组");
assert_eq!(content[1]["source"]["data"], base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok"));
std::fs::remove_file(&path).ok();
}
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
#[test]
fn anthropic_convert_text_only_user_remains_string() {
let _convert = convert_lock();
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"));
}
// ---------- 消息级缓存断点(apply_message_cache_breakpoint,纯函数直测) ----------
// 注:不开全局 ANTHROPIC_CACHE_ENABLED(convert_request 层开关默认关,已有断言不受扰),
// 直接测纯函数落点,避免进程级开关与并行测试互踩。
/// 末条 user(漂移边界:易变尾段/本轮新问题)不打断点,标其前一条稳定历史消息。
#[test]
fn cache_breakpoint_marks_message_before_trailing_user() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
// 前一条 assistant 的末块带 cache_control
let a1 = &messages[1];
let blocks = a1["content"].as_array().unwrap();
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
// 末条 user 保持字符串简写,未被打点
assert_eq!(messages[2]["content"].as_str(), Some("q2"));
}
/// string content 升级为单 text block 数组并附带 cache_control。
#[test]
fn cache_breakpoint_upgrades_string_content_to_blocks() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "a1");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
/// 单条消息(无前一条可标)→ 标自身,不 panic。
#[test]
fn cache_breakpoint_single_message_marks_itself() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "only" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
/// 空 messages 不 panic。
#[test]
fn cache_breakpoint_empty_messages_noop() {
let mut messages: Vec<serde_json::Value> = vec![];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
assert!(messages.is_empty());
}
// ---------- 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 _convert = convert_lock();
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 _convert = convert_lock();
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 _convert = convert_lock();
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 _convert = convert_lock();
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 _convert = convert_lock();
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 应通过"
);
}
// ---------- prompt caching:开关驱动 system 形态 ----------
/// 开关控制 system 形态:开 → blocks 数组含 cache_control:ephemeral;关 → 纯字符串。
/// (两态共用进程级全局开关,拆两个用例并行跑会互相污染——单测内顺序切换断言,末尾复位。)
#[test]
fn anthropic_cache_switch_controls_system_shape() {
let _convert = convert_lock();
set_anthropic_cache_enabled(false); // 兜底复位,防污染其他并行用例
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let mk = || CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
ChatMessage::system("a"),
ChatMessage::system("b"),
ChatMessage::user("hi"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
// 开:数组形态 + cache_control(Anthropic prompt caching)
set_anthropic_cache_enabled(true);
let body = provider.convert_request(mk());
match body.system.expect("开关开时应有 system") {
SystemBlock::Cached(blocks) => {
assert_eq!(blocks.len(), 1, "开关开应包成单 blocks 数组");
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "a\n\nb", "多条 system 应以 \\n\\n join 进单个 text 块");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
SystemBlock::Plain(s) => panic!("开关开应为数组形态,实际 Plain: {}", s),
}
// 关:纯字符串(兼容非官方网关,零行为变更)
set_anthropic_cache_enabled(false);
let body = provider.convert_request(mk());
match body.system {
Some(SystemBlock::Plain(s)) => assert_eq!(s, "a\n\nb", "开关关多条 system 应以 \\n\\n join 纯字符串"),
other => panic!("开关关应为纯字符串,实际: {:?}", other),
}
}
}