重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
666 lines
27 KiB
Rust
666 lines
27 KiB
Rust
//! 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 tracing::{debug, error, warn};
|
||
|
||
use crate::provider::{
|
||
CompletionRequest, CompletionResponse, LlmProvider, MessageRole, ProviderFeatures,
|
||
StreamChunk, StreamResult, TokenUsage, ToolCall, ToolCallDelta,
|
||
};
|
||
|
||
// ============================================================
|
||
// Anthropic API 请求/响应结构体
|
||
// ============================================================
|
||
|
||
/// Anthropic 请求体
|
||
#[derive(Debug, Serialize)]
|
||
struct AnthropicRequest {
|
||
model: String,
|
||
messages: Vec<serde_json::Value>,
|
||
max_tokens: u32,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
system: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
temperature: Option<f32>,
|
||
stream: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
tools: Option<Vec<AnthropicToolDef>>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
tool_choice: Option<serde_json::Value>,
|
||
}
|
||
|
||
/// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters)
|
||
#[derive(Debug, Serialize)]
|
||
struct AnthropicToolDef {
|
||
name: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
description: Option<String>,
|
||
input_schema: serde_json::Value,
|
||
}
|
||
|
||
/// Anthropic 同步响应
|
||
#[derive(Debug, Deserialize)]
|
||
struct AnthropicResponse {
|
||
#[allow(dead_code)]
|
||
id: String,
|
||
model: String,
|
||
content: Vec<AnthropicContentBlock>,
|
||
#[allow(dead_code)]
|
||
stop_reason: Option<String>,
|
||
usage: AnthropicUsage,
|
||
}
|
||
|
||
/// 响应 content 块(text 或 tool_use)
|
||
#[derive(Debug, Deserialize)]
|
||
struct AnthropicContentBlock {
|
||
#[serde(rename = "type")]
|
||
block_type: String,
|
||
#[serde(default)]
|
||
text: Option<String>,
|
||
/// tool_use 块字段
|
||
id: Option<String>,
|
||
name: Option<String>,
|
||
input: Option<serde_json::Value>,
|
||
}
|
||
|
||
#[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<TokenUsage>) -> 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 }
|
||
}
|
||
};
|
||
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 }
|
||
}
|
||
// 消息增量: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 }
|
||
}
|
||
// 文本增量
|
||
"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 };
|
||
}
|
||
// 工具入参增量
|
||
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,
|
||
};
|
||
}
|
||
}
|
||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: 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 id = cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||
return StreamChunk {
|
||
delta: String::new(),
|
||
finished: false,
|
||
tool_calls: Some(vec![ToolCallDelta {
|
||
index: idx,
|
||
id,
|
||
function_name: name,
|
||
function_arguments: None,
|
||
}]),
|
||
usage: None,
|
||
};
|
||
}
|
||
}
|
||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||
}
|
||
// 消息结束:带出累积 usage
|
||
"message_stop" => StreamChunk {
|
||
delta: String::new(),
|
||
finished: true,
|
||
tool_calls: None,
|
||
usage: usage_accum.take(),
|
||
},
|
||
// 错误事件
|
||
"error" => {
|
||
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
|
||
error!(%msg, "Anthropic 流式错误事件");
|
||
StreamChunk { delta: String::new(), finished: true, tool_calls: None, usage: None }
|
||
}
|
||
// content_block_stop / ping 等不产出 chunk
|
||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: 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<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> 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<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
|
||
pending_tool_results.push(serde_json::json!({
|
||
"type": "tool_result",
|
||
"tool_use_id": m.tool_call_id,
|
||
"content": m.content,
|
||
}));
|
||
}
|
||
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<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 {
|
||
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<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 }));
|
||
}
|
||
|
||
/// 统一鉴权头: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);
|
||
|
||
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);
|
||
}
|
||
|
||
let resp: AnthropicResponse = resp.json().await?;
|
||
|
||
// content 块中拼接 text,收集 tool_use
|
||
let mut text = String::new();
|
||
let mut tool_calls: Vec<ToolCall> = 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 = block.id.unwrap_or_default();
|
||
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,
|
||
};
|
||
|
||
Ok(CompletionResponse {
|
||
text,
|
||
model: resp.model,
|
||
usage,
|
||
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
||
})
|
||
}
|
||
|
||
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
|
||
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<TokenUsage> = 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) => {
|
||
error!(error = %e, "Anthropic SSE 事件流错误");
|
||
Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e))
|
||
}
|
||
});
|
||
|
||
Ok(Box::pin(stream))
|
||
}
|
||
|
||
fn name(&self) -> &str {
|
||
"anthropic-compat"
|
||
}
|
||
|
||
fn supported_features(&self) -> ProviderFeatures {
|
||
ProviderFeatures {
|
||
streaming: true,
|
||
function_calling: true,
|
||
vision: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 单测(不发真实 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<TokenUsage> = 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<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 事件 → finished=true 终态空 chunk
|
||
#[test]
|
||
fn anthropic_error_event_finishes_stream() {
|
||
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 应终止流");
|
||
assert!(c.usage.is_none(), "error 不带出 usage");
|
||
assert!(acc.is_some(), "error 不应清空已累积的 usage(与原实现一致)");
|
||
}
|
||
|
||
/// 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());
|
||
}
|
||
}
|