Files
DevFlow/crates/df-ai/src/anthropic_compat.rs
绝尘 cf017f81e2 新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 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/儿童每日打卡应用/ 与本项目无关,已排除。
2026-06-14 14:08:20 +08:00

666 lines
27 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) 无需感知协议。
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_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
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_tokensmessage_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_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 事件 → 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());
}
}