Files
DevFlow/crates/df-ai/src/anthropic_helpers.rs
T
lxy 7aa0da32f2 修复: 协议层与体验缺陷六件
- temperature 按模型适配,仅收 1 的模型不再 400
- 流式 error 帧不再被 choices 必填整帧丢弃
- 只读缓存写后失效
- git diff 中文截断撞 UTF-8 边界 panic
- 非视觉模型纯图片直接拒绝
- 全局快捷键补输入焦点守卫
2026-10-04 16:27:31 +08:00

287 lines
15 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 协议适配 — 协议数据结构与 SSE 事件解析(纯函数)。
//!
//! 本模块从 `anthropic_compat.rs` 抽离,承载与 HTTP 无关的纯协议逻辑:
//! - 请求/响应结构体(`AnthropicRequest` 等)
//! - 协议常量(`ANTHROPIC_VERSION` / `DEFAULT_MAX_TOKENS`)
//! - SSE 事件 → `StreamChunk` 转换纯函数(`apply_anthropic_event`)
//!
//! Provider struct + impl(含 HTTP 调用)仍留在 `anthropic_compat.rs`,
//! Rust impl 块不可跨文件,故仅搬迁 impl 块外部的类型/常量/纯函数。
//! 零行为变更(纯搬迁)。
use serde::{Deserialize, Serialize};
use tracing::{error, warn};
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
// ============================================================
// Anthropic API 请求/响应结构体
// ============================================================
/// Anthropic `system` 字段形态(受 prompt caching 开关 `ANTHROPIC_CACHE_ENABLED` 控制):
/// - `Plain`:纯字符串。开关关时保持原形态,兼容非官方网关(GLM 订阅端点 / 任意 Messages API
/// 代理——部分网关会拒收 cache_control 字段)。
/// - `Cached`:blocks 数组 `[{type:text, text, cache_control:{type:ephemeral}}]`。开关开时启用
/// Anthropic prompt caching,让 system 稳定段在支持缓存的端点上命中。
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub(crate) enum SystemBlock {
/// 纯字符串形态(兼容网关,开关关)
Plain(String),
/// blocks 数组形态(含 cache_control,开关开)
Cached(Vec<serde_json::Value>),
}
/// Anthropic 请求体
#[derive(Debug, Clone, Serialize)]
pub(crate) struct AnthropicRequest {
pub model: String,
pub messages: Vec<serde_json::Value>,
pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<SystemBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<AnthropicToolDef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
}
/// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters)
#[derive(Debug, Clone, Serialize)]
pub(crate) struct AnthropicToolDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: serde_json::Value,
}
/// Anthropic 同步响应
#[derive(Debug, Deserialize)]
pub(crate) struct AnthropicResponse {
// Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)]
id: String,
pub model: String,
pub content: Vec<AnthropicContentBlock>,
// Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)]
stop_reason: Option<String>,
pub usage: AnthropicUsage,
}
/// 响应 content 块(text / tool_use / thinking)
#[derive(Debug, Deserialize)]
pub(crate) struct AnthropicContentBlock {
#[serde(rename = "type")]
pub block_type: String,
#[serde(default)]
pub text: Option<String>,
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
#[serde(default)]
pub thinking: Option<String>,
/// tool_use 块字段
pub id: Option<String>,
pub name: Option<String>,
pub input: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AnthropicUsage {
pub input_tokens: u32,
pub output_tokens: u32,
/// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。
/// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。
/// 非 cache 场景无此字段 → serde default 0。
#[serde(default)]
pub cache_creation_input_tokens: u32,
/// Anthropic prompt caching 扩展:cache 命中读取 token(低价)。
/// 映射到 TokenUsage.prompt_cache_hit_tokens。
/// 非 cache 场景无此字段 → serde default 0。
#[serde(default)]
pub cache_read_input_tokens: u32,
}
// ============================================================
// 协议常量
// ============================================================
/// Anthropic 流式协议版本头
pub(crate) const ANTHROPIC_VERSION: &str = "2023-06-01";
/// Anthropic max_tokens 必填,缺省时的兜底值
pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
// ============================================================
// 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/thinking_delta) → 文本/工具入参/思考增量,
/// signature_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, error: None, reasoning_content: None, truncated_by_max_tokens: false }
}
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)。
// anthropic prompt caching:cache_creation/read 在 message_start.usage 携带。
"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())
{
let u_obj = v.get("message").and_then(|m| m.get("usage"));
let cache_read = u_obj
.and_then(|u| u.get("cache_read_input_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as u32;
let cache_creation = u_obj
.and_then(|u| u.get("cache_creation_input_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as u32;
*usage_accum = Some(TokenUsage {
prompt_tokens: inp as u32,
completion_tokens: 0,
total_tokens: inp as u32,
prompt_cache_hit_tokens: cache_read,
prompt_cache_miss_tokens: cache_creation,
reasoning_tokens: 0,
});
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens: false }
}
// 消息增量: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::default());
acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens);
}
// stop_reason=max_tokens(message_delta 帧携带):输出被 max_tokens 截断。
// 终态 finished 在后续 message_stop 帧,截断标志挂本帧,消费方跨帧 OR 累积。
let truncated_by_max_tokens = v.get("delta").and_then(|d| d.get("stop_reason")).and_then(|s| s.as_str()) == Some("max_tokens");
// 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens }
}
// 文本增量
"content_block_delta" => {
if let Some(delta) = v.get("delta") {
// thinking 增量:推理正文走 reasoning_content 通道(与 DeepSeek 同路入库展示),
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") {
let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text), truncated_by_max_tokens: false };
}
// signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
}
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
}
// 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
return StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id: None,
function_name: None,
function_arguments: Some(partial),
}]),
usage: None,
error: None,
reasoning_content: None,
truncated_by_max_tokens: false,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
}
// 工具块开始:带 id + name
"content_block_start" => {
// thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出;
// redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。
if let Some(cb) = v.get("content_block") {
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
// CR-空 id:id 缺失/空时用 fallback 兜底(流式后续 input_json_delta 按 index 累加,
// 中途无法整体跳过)。与同步路径 + OpenAI 路径共用 tool_call_id_or_fallback(DRY),
// prefix=`gen_anthropic_stream` 区分来源。非空原样。
let raw_id = cb.get("id").and_then(|t| t.as_str()).unwrap_or("");
let id = if raw_id.is_empty() {
let fallback = tool_call_id_or_fallback(raw_id, idx as usize, "gen_anthropic_stream");
warn!(%fallback, name = ?name, "Anthropic 流式 tool_use 块 id 为空,已生成 fallback id(原样回传会触发 GLM 500)");
Some(fallback)
} else {
Some(raw_id.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,
error: None,
reasoning_content: None,
truncated_by_max_tokens: false,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
}
// 消息结束:带出累积 usage
"message_stop" => StreamChunk {
delta: String::new(),
finished: true,
tool_calls: None,
usage: usage_accum.take(),
error: None,
reasoning_content: None,
truncated_by_max_tokens: false,
},
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
"error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
error!(%msg, "Anthropic 流式错误事件");
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None, truncated_by_max_tokens: false }
}
// content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false },
}
}