Files
DevFlow/crates/df-ai/src/openai_compat.rs
T
lxy c1e5b96d75 修复: 上下文流式与工具签名域清债
- tool_signature 归一化补参,合法换参不再误触探索熔断
- SSE 超限截头改对齐事件边界保尾,流不再永久破损
- content_filter 纳入终态判定,无 DONE 流也收敛
- sanitize 占位补头前缀短路只吞 orphan 的盲区消除
- intent 关键词加词边界,history 不再误判
- 恢复重放后重置话题锚点,URL 图 token 地板不被稀释
2026-10-03 15:35:05 +08:00

1402 lines
62 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.
//! OpenAI 兼容 Provider — 通过 /v1/chat/completions 端点实现
//!
//! 覆盖: OpenAI / GLM (open.bigmodel.cn) / DeepSeek / Claude OpenAI 兼容模式
//! 支持: 同步调用 + SSE 流式 + Function Calling / Tool Use
use std::time::Duration;
use async_trait::async_trait;
use futures::StreamExt;
use reqwest::Client;
use tracing::{debug, error, warn};
use crate::provider::{
tool_call_id_or_fallback, CompletionRequest, CompletionResponse, LlmProvider, 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,
};
// 协议数据结构(OpenAiRequest / OpenAiResponse / OpenAiStreamChunk …)与 SSE 解析纯函数
// apply_openai_sse 抽离到 openai_helpers.rs,结构对齐 anthropic_helpers.rs。
// Rust impl 块不可跨文件,Provider struct + impl 仍留本文件。
use crate::openai_helpers::{
apply_openai_sse, OpenAiMessage, OpenAiRequest, OpenAiResponse, OpenAiToolCallResp,
};
// ============================================================
// OpenAI Compat Provider
// ============================================================
/// OpenAI 兼容 LLM Provider
pub struct OpenAICompatProvider {
client: Client,
api_key: String,
base_url: String,
default_model: String,
}
impl OpenAICompatProvider {
/// 创建 Provider
///
/// - `base_url`: 如 "https://api.openai.com", "https://open.bigmodel.cn/api/paas", "https://api.deepseek.com"
/// - `api_key`: API 密钥
/// - `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(与 Anthropic 共用 DRY)。
// connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client)。
let client = crate::build_provider_client();
Self {
client,
api_key: api_key.into(),
base_url: base_url.into(),
default_model: default_model.into(),
}
}
/// 构建完整 API URL
///
/// 智能拼接,兼容三种 base_url 约定:
/// - 已含完整端点(…/chat/completions)→ 直接用
/// - 已含版本段(…/v1 …/v4 等,如 GLM 的 /api/paas/v4)→ 补 /chat/completions
/// - 仅域名无版本(如 api.openai.com / api.deepseek.com)→ 补 /v1/chat/completions(OpenAI 约定)
fn chat_url(&self) -> String {
let base = self.base_url.trim_end_matches('/');
if base.ends_with("/chat/completions") {
return base.to_string();
}
if Self::ends_with_version(base) {
return format!("{}/chat/completions", base);
}
format!("{}/v1/chat/completions", base)
}
/// base_url 是否以 `/v<数字>` 结尾(如 /v1 /v4)
fn ends_with_version(base: &str) -> bool {
match base.rsplit_once('/') {
Some((_, last)) if last.starts_with('v') && last.len() > 1 => {
last[1..].bytes().all(|b| b.is_ascii_digit())
}
_ => false,
}
}
/// 构建 embeddings API URL(与 chat_url 同套智能拼接规则)
fn embed_url(&self) -> String {
let base = self.base_url.trim_end_matches('/');
if base.ends_with("/embeddings") {
return base.to_string();
}
if Self::ends_with_version(base) {
return format!("{}/embeddings", base);
}
format!("{}/v1/embeddings", base)
}
/// 将通用请求转换为 OpenAI 格式
fn convert_request(&self, req: CompletionRequest) -> OpenAiRequest {
let model = if req.model.is_empty() {
self.default_model.clone()
} else {
req.model
};
let mut messages: Vec<OpenAiMessage> = req
.messages
.into_iter()
.map(|m| {
let role = match m.role {
crate::provider::MessageRole::System => "system",
crate::provider::MessageRole::User => "user",
crate::provider::MessageRole::Assistant => "assistant",
crate::provider::MessageRole::Tool => "tool",
};
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
let content = if m.has_image() {
let parts: Vec<serde_json::Value> = m
.flattened_parts()
.into_iter()
.map(|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 final_url = match (base64, url, media_type) {
(Some(b), _, Some(mt)) => {
format!("data:{};base64,{}", mt, b)
}
(None, Some(u), _) => u,
// 完整性兜底:当前 image_base64 构造器强制 media_type:Some,
// image_url 构造器提供 url:Some,二者分别命中上两个分支;
// 此分支仅在 parts 来源被外部直接构造且字段均缺时才可达
//(如 url:None+base64:None 或 base64:Some+media_type:None)。
// 退化为空串(OpenAI 对空 image_url.url 会 400),
// 由调用方保证 parts 合法性,provider 层不做静默伪造。
_ => String::new(),
};
serde_json::json!({
"type": "image_url",
"image_url": { "url": final_url },
})
}
})
.collect();
serde_json::Value::Array(parts)
} else {
serde_json::Value::String(m.content.clone())
};
let tool_calls = m.tool_calls.map(|calls| {
calls
.into_iter()
.map(|tc| {
serde_json::json!({
"id": tc.id,
"type": tc.call_type,
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
}
})
})
.collect()
});
OpenAiMessage {
role: role.to_string(),
content,
tool_call_id: m.tool_call_id,
tool_calls,
reasoning_content: m.reasoning_content,
}
})
.collect();
// 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
Self::ensure_leading_user(&mut messages);
// 治 DeepSeek/OpenAI 400(三元组完整性 P0)。OpenAI 协议铁律:
// (a) assistant 的每个 tool_call.id 必须有后续 tool(role=tool, tool_call_id 匹配)响应,
// 否则 "insufficient tool messages" 400(assistant 调了工具但无结果)。
// (b) 反之,每条 tool 消息必须紧跟一个含 tool_calls(同 tool_call_id)的 assistant,
// 否则 "Messages with role tool must be a response to a preceding message
// with tool_calls" 400(tool 无配对头)。
//
// 旧逻辑只检查「下一条 role 是否为 tool」(粗粒度),漏两类 orphan:
// 1) 部分 tool_call 无响应:assistant(tc=[a,b]) → tool(a)(b 丢失)→ 旧逻辑因下一条是
// tool 不剥 → 发出未闭合的 b → 400。修法:按 tool_call_id 精确配对,剥未闭合 id。
// 2) orphan tool_result(tool 无前置 assistant tool_calls 配对):DB/直构造路径绕过
// ContextManager::sanitize_messages(标题/知识注入/工作流节点),tool 残留无头 →
// 旧逻辑不处理 → 400。修法:剥 assistant tool_calls 时同步丢弃同 id 的 orphan
// tool(一致性:不留无头 result),并对独立 orphan tool(全程无配对头)直接丢弃。
//
// 正常三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b),各 id 闭合,
// 本守卫零介入。仅异常截断/恢复/直构造路径触发(防 400 兜底)。
// view-only:仅改发送视图(本函数消费 req.messages 所有权),持久化由调用方/上层 sanitize 全量保留。
sanitize_openai_triplets(&mut messages);
let tools = req.tools.map(|defs| {
defs.into_iter()
.map(|d| serde_json::to_value(d).unwrap_or_default())
.collect()
});
OpenAiRequest {
model,
messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
stream: req.stream,
tools,
tool_choice: req.tool_choice,
reasoning_content: req.reasoning_content,
// 流式请求末 chunk 带 usage(同步调用 complete 不需要)
stream_options: if req.stream {
Some(serde_json::json!({ "include_usage": true }))
} else {
None
},
}
}
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
/// 流中途 error 时附摘要定位哪条非法(对齐 `AnthropicCompatProvider::summarize_messages`)。
fn summarize_openai_messages(messages: &[OpenAiMessage]) -> String {
let lines: Vec<String> = messages
.iter()
.enumerate()
.map(|(i, m)| {
let role = m.role.as_str();
let desc = match &m.content {
serde_json::Value::String(s) => format!("text({}B)", s.len()),
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)
),
"image_url" => "image".to_string(),
_ => ty.to_string(),
}
})
.collect();
format!("[{}]", parts.join(","))
}
_ => "?".to_string(),
};
let tool_mark = match (&m.tool_calls, &m.tool_call_id) {
(Some(tcs), _) => format!(" tool_calls={}", tcs.len()),
(None, Some(tid)) => format!(" tool_result[tid={}]", tid),
(None, None) => String::new(),
};
format!("#{}:{} {}{}", i, role, desc, tool_mark)
})
.collect();
format!("{} msgs: {}", lines.len(), lines.join(" | "))
}
/// 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
///
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
/// assistant 的序列——会话恢复、续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉。
///
/// **用"补"而非"砍"**:开头插一条 user 占位,保留全部上下文(砍会丢工具调用历史,多轮砍到空)。
/// 占位 user 紧贴原首条,不破坏 user/assistant 交替;仅异常路径触发(正常首条本就是 user)。
fn ensure_leading_user(messages: &mut Vec<OpenAiMessage>) {
let first_role = messages.first().map(|m| m.role.as_str()).unwrap_or("");
if first_role == "user" || first_role == "system" {
return;
}
warn!(
first_role,
msg_count = messages.len(),
"ensure_leading_user: 首条非 user/system,补 user 占位(保留上下文,防 OpenAI 首条 assistant/tool 非法)"
);
messages.insert(
0,
OpenAiMessage {
role: "user".into(),
content: serde_json::Value::String("(continued from previous context)".into()),
tool_call_id: None,
tool_calls: None,
reasoning_content: None,
},
);
}
/// 解析同步响应中的工具调用。
///
/// 兜底(CR-空 id):id 空时按数组 index 生成 `gen_tool_{index}` fallback。
/// SenseNova 等兼容缺陷 provider 发空 id,多 tool_call 同 id 致结果路由全落首个。
/// 详见 `tool_call_id_or_fallback`。正常 provider id 非空原样透传。
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
calls
.into_iter()
.enumerate()
.map(|(i, c)| {
let id = tool_call_id_or_fallback(&c.id, i, "gen_tool");
ToolCall::new(id, c.function.name, c.function.arguments)
})
.collect()
}
}
/// 从 OpenAiMessage 的 tool_calls 数组里取每个 call 的 id(tool_calls 形如
/// [{id, type, function:{name, arguments}}, ...])。非数组 / 缺 id 的条目跳过。
fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec<String> {
let Some(arr) = msg.tool_calls.as_ref() else {
return Vec::new();
};
arr.iter()
.filter_map(|tc| tc.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
.collect()
}
/// 三元组一致性自愈(view-only,发送视图):保证 OpenAI 协议 tool_call/tool_result
/// 双向闭合,防 DeepSeek/OpenAI 400。详见 [`OpenAICompatProvider::convert_request`] 调用处注释。
///
/// 两轮扫描:
/// 1) 收集 resolved_ids = 所有 tool 消息的 tool_call_id(这些 id 有 result 响应)。
/// 2) assistant(tool_calls):剥未在 resolved_ids 内的 call.id;剥空则 tool_calls=None。
/// (头被剥后,其 tool_call.id 不再进 head_ids,故 step3 会同步丢弃对应 orphan tool。)
/// 3) tool:tool_call_id 不在任何保留 assistant 头(任意 assistant 仍含此 id)→ orphan
/// tool_result,丢弃。这覆盖「头被剥后残留的 tool」与「全程无配对头的 tool」两类。
///
/// 一致性:剥 assistant tool_call → 该 id 不进 head_ids → 对应 tool 在 step3 被丢;
/// 反之剥 orphan tool 不动 assistant(若 assistant 的所有 id 都被剥则 tool_calls=None)。
/// 正常三元组(各 id 闭合)零介入。
///
/// 设计取舍:OpenAI 协议 assistant(tool_calls) 需有效函数结构,补头(像 Anthropic
/// TOOL_MISSING_PREFIX)风险高于丢弃——故选「丢弃 orphan」而非「补头」。Anthropic
/// 路径由 drop_reverse_orphans 补头自愈(保留 LLM 可见的工具结果);OpenAI 路径走丢弃,
/// 二者各自适配协议特性(Anthropic 严格交替 + 补头可行;OpenAI tool 必须紧跟 tool_calls)。
fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
use std::collections::HashSet;
// step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。
let resolved_ids: HashSet<String> = messages
.iter()
.filter(|m| m.role == "tool")
.filter_map(|m| m.tool_call_id.clone())
.collect();
let mut stripped_heads = 0u32;
let mut total_stripped = 0u32;
// step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。
for m in messages.iter_mut() {
if m.role != "assistant" {
continue;
}
let Some(calls) = m.tool_calls.as_ref() else {
continue;
};
if calls.is_empty() {
continue;
}
let kept: Vec<serde_json::Value> = calls
.iter()
.filter(|tc| {
tc.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| resolved_ids.contains(id))
})
.cloned()
.collect();
let stripped_count = calls.len() - kept.len();
if stripped_count == 0 {
continue;
}
m.tool_calls = if kept.is_empty() { None } else { Some(kept) };
stripped_heads += 1;
total_stripped += stripped_count as u32;
tracing::warn!(
stripped_count,
"[openai] assistant 含未闭合 tool_calls(无对应 tool result),已剥离 {} 个(防 insufficient tool messages 400)",
stripped_count,
);
}
// step 3:head_ids = step2 后仍保留在任意 assistant 头的 id(有头配对的 tool 才保留)。
let head_ids: HashSet<String> = messages
.iter()
.filter(|m| m.role == "assistant")
.flat_map(extract_tool_call_ids)
.collect();
let original_len = messages.len();
let mut dropped_orphan_tools = 0u32;
messages.retain(|m| {
if m.role != "tool" {
return true;
}
let id = match m.tool_call_id.as_deref() {
None => {
// 无 tool_call_id 的 tool 消息(异常数据):无法配对,丢弃(发出去必 400)。
dropped_orphan_tools += 1;
tracing::warn!(
"[openai] tool 消息缺少 tool_call_id,已丢弃(无 id 无法配对 assistant tool_calls,防 400)"
);
return false;
}
Some(id) => id,
};
if head_ids.contains(id) {
// 有配对头 → 保留(正常三元组)。
return true;
}
// 无配对头(id 不在任何保留 assistant 头内)→ orphan tool_result,丢弃。
// 含两类:(a) assistant 头被 step2 剥后残留的 tool;(b) 全程无配对头的直构造/DB 残留。
dropped_orphan_tools += 1;
tracing::debug!(
tool_call_id = %id,
"[openai] orphan tool result(无配对 assistant tool_calls),已丢弃(防 'tool must be response to preceding tool_calls' 400)",
);
false
});
if stripped_heads > 0 || dropped_orphan_tools > 0 {
tracing::debug!(
stripped_heads,
total_stripped,
dropped_orphan_tools,
before = original_len,
after = messages.len(),
"[openai] tool_call 三元组自愈(view-only, 持久化不受影响)"
);
}
}
#[async_trait]
impl LlmProvider for OpenAICompatProvider {
/// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用)
async fn embed(&self, model: &str, texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
#[derive(serde::Deserialize)]
struct EmbedData { embedding: Vec<f32>, index: usize }
#[derive(serde::Deserialize)]
struct EmbedResponse { data: Vec<EmbedData> }
let resp = self
.client
.post(self.embed_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&serde_json::json!({ "model": model, "input": texts }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Embedding API 错误 {}: {}", status, body);
}
let mut body: EmbedResponse = resp.json().await?;
// 按 index 排序保证与输入顺序一致(API 不保证返回顺序)
body.data.sort_by_key(|d| d.index);
Ok(body.data.into_iter().map(|d| d.embedding).collect())
}
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse> {
let mut req = request;
req.stream = false;
let openai_req = self.convert_request(req);
debug!(model = %openai_req.model, "OpenAI 同步调用");
// 指数退避重试: 包裹 send + 状态码判定。
// 单请求 60s timeout 保持不变,重试是额外层: 3 次 × 60s 最坏 180s,
// 由 retry_with_backoff 内部 30s 总预算主动止损。
let label = format!("OpenAI[{}]", openai_req.model);
retry_with_backoff(&label, move |_| {
let client = self.client.clone();
let url = self.chat_url();
let api_key = self.api_key.clone();
let openai_req = openai_req.clone();
async move {
// send
let resp = client
.post(url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60))
.json(&openai_req)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
// 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!("请求失败(不可重试): {}", e));
}
};
// 状态码判定
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
let msg = format!("LLM API 错误 {}: {}", status, body);
if is_status_retryable(status) {
warn!(%status, "OpenAI 同步调用可重试状态码");
return AttemptOutcome::Retryable(msg);
}
error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg);
}
// body 解析:text 先取再 serde 分层判定:读取中断(text Err,reqwest 归
// is_decode 类,重发幂等安全)可重试;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 body: OpenAiResponse = match serde_json::from_str(&text) {
Ok(b) => b,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
let choice = match body.choices.into_iter().next() {
Some(c) => c,
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
};
let text = choice.message.content.unwrap_or_default();
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
let usage = body.usage.map(|u| TokenUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
prompt_cache_hit_tokens: u.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: u.prompt_cache_miss_tokens,
reasoning_tokens: u.reasoning_tokens,
}).unwrap_or_default();
AttemptOutcome::Ok(CompletionResponse {
text,
model: body.model,
usage,
tool_calls,
reasoning_content: choice.message.reasoning_content,
})
}
})
.await
}
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
let mut req = request;
req.stream = true;
let openai_req = self.convert_request(req);
debug!(model = %openai_req.model, "OpenAI 流式调用");
// send 阶段需 timeout 防 hang(同 Anthropic 路径)。
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
let send_future = self
.client
.post(self.chat_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&openai_req)
.send();
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
// 谓词结构化分类:Display 串不含 source 关键词("error sending request for url"),
// 纯串分类必 unknown → Fatal 零重试(陈旧连接首包 RST 就此裸奔)。类词置串首,
// source 链兜底真实传输词(reset/broken pipe 等),下游按类词走可重试。
let class = crate::send_error_class(crate::SendErrPreds::of(&e));
tracing::error!(
error = %e,
class = class,
url = %self.chat_url(),
source = ?std::error::Error::source(&e),
"OpenAI 流式 send 失败"
);
anyhow::bail!(
"流式 send 失败({}): {} [src={:?}]",
class,
e,
std::error::Error::source(&e)
);
}
Err(_elapsed) => {
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
}
};
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
error!(%status, %body, "LLM 流式 API 调用失败");
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
}
// 原生 SSE 解析器替代 eventsource-stream 库。
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
let mut last_usage: Option<TokenUsage> = None;
// MidStream error(中转站按 OpenAI 协议在流中途发 error 帧)时附 messages 摘要定位哪条非法
// (对齐 anthropic_compat 672)。
let messages_summary = Self::summarize_openai_messages(&openai_req.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_openai_sse(&data, &mut last_usage);
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
}
chunks.push(Ok(chunk));
}
}
Err(e) => {
let ctx = format!("SSE 流错误: {}", e);
error!("{}", ctx);
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
}
}
futures::stream::iter(chunks)
});
Ok(Box::pin(stream))
}
fn name(&self) -> &str {
&self.default_model
}
fn endpoint(&self) -> String {
self.chat_url()
}
}
// ============================================================
// 单测(不发真实 HTTP,喂构造的 SSE data 字符串序列)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::openai_helpers::OpenAiFunctionResp;
/// 辅助:构造普通文本 delta chunk 的 SSE data
fn text_chunk(content: &str, finish_reason: Option<&str>) -> String {
let fr = match finish_reason {
Some(r) => format!(", \"finish_reason\": \"{}\"", r),
None => String::from(", \"finish_reason\": null"),
};
format!(
r#"{{"choices":[{{"delta":{{"content":"{}"}}{}}}]}}"#,
content, fr
)
}
/// 辅助:构造带 usage 的 chunk(choices 为空 → usage-only 末 chunk,对应 include_usage)
fn usage_only_chunk(prompt: u32, completion: u32) -> String {
format!(
r#"{{"choices":[],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
prompt,
completion,
prompt + completion
)
}
/// 辅助:构造既有 content 又带 usage 的末段 chunk(部分兼容端点会把 usage 挂到正常末 chunk 上)
fn text_chunk_with_usage(content: &str, finish_reason: &str, prompt: u32, completion: u32) -> String {
format!(
r#"{{"choices":[{{"delta":{{"content":"{}"}},"finish_reason":"{}"}}],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
content,
finish_reason,
prompt,
completion,
prompt + completion
)
}
/// 多 chunk 文本流后,末 chunk 携带 usage(include_usage 覆盖语义)
#[test]
fn openai_sse_multi_chunk_with_terminal_usage() {
let mut acc: Option<TokenUsage> = None;
// 1) 首个文本增量,无 usage
let c = apply_openai_sse(&text_chunk("Hello", None), &mut acc);
assert_eq!(c.delta, "Hello");
assert!(!c.finished);
assert!(c.usage.is_none());
assert!(acc.is_none(), "无 usage 的 chunk 不应改累加器");
// 2) 第二个文本增量
let c = apply_openai_sse(&text_chunk(" world", None), &mut acc);
assert_eq!(c.delta, " world");
assert!(!c.finished);
assert!(acc.is_none());
// 3) 末段正常 chunk 带 finish_reason=stop(仍是文本 delta,不带 usage)
let c = apply_openai_sse(&text_chunk("", Some("stop")), &mut acc);
assert!(c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none(), "此 chunk 无 usage 字段,累加器仍为 None");
// 4) usage-only chunk(choices=[])携带累计 usage → 覆盖累加器,并同帧带出快照
// (6bafdcd 起 usage 与累加器同帧挂载,中途断连也能拿到真实 usage,非仅 [DONE] 带出)
let c = apply_openai_sse(&usage_only_chunk(12, 34), &mut acc);
assert!(!c.finished);
let u = c.usage.expect("usage-only chunk 应同帧带出累积 usage 快照");
assert_eq!(u.prompt_tokens, 12);
assert_eq!(u.completion_tokens, 34);
assert_eq!(u.total_tokens, 46);
let acc = acc.expect("累加器应已被 usage-only chunk 覆盖写入");
assert_eq!(acc.prompt_tokens, 12);
assert_eq!(acc.completion_tokens, 34);
assert_eq!(acc.total_tokens, 46);
}
/// usage 挂在正常末段 chunk(含 finish_reason)上,而非独立 usage-only chunk
#[test]
fn openai_sse_usage_on_terminal_text_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse(&text_chunk_with_usage("", "stop", 100, 200), &mut acc);
assert!(c.finished);
// 6bafdcd 起 usage 与累加器同帧挂载:finish 帧即带快照,下游不必等 [DONE]
let u = c.usage.expect("finish 帧应同帧带出累积 usage 快照");
assert_eq!(u.prompt_tokens, 100);
assert_eq!(u.completion_tokens, 200);
assert_eq!(u.total_tokens, 300);
let acc = acc.expect("末段 chunk 的 usage 应已覆盖累加器");
assert_eq!(acc.prompt_tokens, 100);
assert_eq!(acc.completion_tokens, 200);
assert_eq!(acc.total_tokens, 300);
}
/// [DONE] 时 take() 带出累积 usage,且取走后累加器清空
#[test]
fn openai_sse_done_takes_accumulated_usage() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&text_chunk("x", None), &mut acc);
apply_openai_sse(&usage_only_chunk(5, 7), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
assert!(c.finished);
let u = c.usage.expect("[DONE] 应带出累积 usage");
assert_eq!(u.prompt_tokens, 5);
assert_eq!(u.completion_tokens, 7);
assert_eq!(u.total_tokens, 12);
assert!(acc.is_none(), "take() 后累加器应清空");
}
/// 无 usage 的流:[DONE] 时 usage 字段为 None
#[test]
fn openai_sse_done_without_usage() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&text_chunk("hi", None), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "全程无 usage 时 [DONE] usage 应为 None");
assert!(acc.is_none());
}
/// 后续 usage chunk 覆盖先前 usage(多轮 / 重发场景)
#[test]
fn openai_sse_later_usage_overrides_earlier() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&usage_only_chunk(1, 1), &mut acc);
apply_openai_sse(&usage_only_chunk(50, 60), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
let u = c.usage.unwrap();
assert_eq!(u.prompt_tokens, 50, "末 usage 应覆盖前值");
assert_eq!(u.completion_tokens, 60);
assert_eq!(u.total_tokens, 110);
}
/// finish_reason=length(max_tokens 截断)按正常终止处理
#[test]
fn openai_sse_length_finish_reason_treated_as_finished() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse(&text_chunk("...", Some("length")), &mut acc);
assert!(c.finished, "length 应视为正常终止");
assert!(acc.is_none());
}
/// 非法 JSON data → 返回空 chunk,不 panic、不改累加器
#[test]
fn openai_sse_malformed_json_yields_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse("not a json", &mut acc);
assert_eq!(c.delta, "");
assert!(!c.finished);
assert!(c.usage.is_none());
assert!(acc.is_none());
}
/// tool_calls 增量解析
#[test]
fn openai_sse_tool_call_delta() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"q\":"}}]}}]}"#;
let c = apply_openai_sse(data, &mut acc);
assert!(acc.is_none());
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
assert_eq!(tcs.len(), 1);
assert_eq!(tcs[0].index, 0);
assert_eq!(tcs[0].id.as_deref(), Some("call_1"));
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
assert!(!c.finished);
}
/// 流中途 error 事件 → error 为 Some(msg),finished=false(避免残缺被当正常完成入库),不污染 usage 累加
#[test]
fn openai_sse_midstream_error_event() {
let mut acc: Option<TokenUsage> = None;
// 先累积一段 usage,验证 error 分支不污染累加器
apply_openai_sse(&usage_only_chunk(10, 20), &mut acc);
let data = r#"{"choices":[],"error":{"message":"context length exceeded","type":"invalid_request_error"}}"#;
let c = apply_openai_sse(data, &mut acc);
assert!(!c.finished, "error 帧不应走 finished 完成路径");
assert_eq!(c.delta, "");
assert!(c.tool_calls.is_none());
assert!(c.usage.is_none(), "error 帧不应带出 usage");
let err = c.error.expect("error 帧应映射为 Some(msg)");
assert_eq!(err, "context length exceeded");
// 累加器保持原值(未被覆盖/清空)
let acc = acc.expect("累加器应保留先前 usage 不受 error 影响");
assert_eq!(acc.prompt_tokens, 10);
assert_eq!(acc.completion_tokens, 20);
}
/// error 无 message 字段 → 兜底 "stream error" 字符串
#[test]
fn openai_sse_midstream_error_without_message_falls_back() {
let mut acc: Option<TokenUsage> = None;
// error 形态异常(只有 type,无 message)
let data = r#"{"choices":[],"error":{"type":"server_error"}}"#;
let c = apply_openai_sse(data, &mut acc);
assert!(!c.finished);
assert_eq!(c.error.as_deref(), Some("stream error"), "无 message 字段应兜底");
}
/// 标准 error 帧不带 choices 键 → 仍须解析出 error(此前 choices 必填,
/// 整帧 serde 失败走空 chunk 分支,error 被吞、流呈无声截断)
#[test]
fn openai_sse_error_frame_without_choices_key_parses() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"error":{"message":"insufficient quota","type":"insufficient_quota"}}"#;
let c = apply_openai_sse(data, &mut acc);
assert!(!c.finished, "error 帧不应走 finished 完成路径");
assert_eq!(c.delta, "");
assert_eq!(c.error.as_deref(), Some("insufficient quota"));
assert!(c.usage.is_none());
}
/// usage 尾帧不带 choices 键(OpenAI include_usage 标准形态)→ 累加进 usage,
/// [DONE] 终态带出(此前 choices 必填,该帧被整帧丢弃,usage 永远拿不到)
#[test]
fn openai_sse_usage_tail_frame_without_choices_key_accumulates() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}"#;
let c = apply_openai_sse(data, &mut acc);
assert_eq!(c.delta, "");
assert!(!c.finished);
let acc_after_tail = acc.as_ref().expect("usage 尾帧应写入累加器");
assert_eq!(acc_after_tail.prompt_tokens, 7);
assert_eq!(acc_after_tail.completion_tokens, 3);
let done = apply_openai_sse("[DONE]", &mut acc);
assert!(done.finished);
let usage = done.usage.expect("[DONE] 应带出累计 usage");
assert_eq!(usage.prompt_tokens, 7);
assert_eq!(usage.total_tokens, 10);
}
// ---------- 多模态 convert_request ----------
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
#[test]
fn openai_convert_multimodal_content() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![ChatMessage::user_parts(
"看图",
vec![
crate::provider::ContentPart::image_base64("image/png", "iVBOR"),
crate::provider::ContentPart::image_url("https://x/a.png"),
],
)],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let msg = &out.messages[0];
// content 是数组:[text "看图", image_url(data URI), image_url(http url)]
let arr = msg.content.as_array().expect("含图 → content 数组");
assert_eq!(arr.len(), 3);
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[0]["text"], "看图");
assert_eq!(arr[1]["type"], "image_url");
assert_eq!(
arr[1]["image_url"]["url"],
"data:image/png;base64,iVBOR"
);
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
}
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
#[test]
fn openai_convert_text_only_remains_string() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![ChatMessage::user("hello")],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let msg = &out.messages[0];
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
}
// ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
/// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
#[test]
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::assistant("我来帮你"),
ChatMessage::user("继续"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 3, "占位 + 原 2 条");
assert_eq!(out.messages[0].role.as_str(), "user", "首条应为 user(补占位)");
assert_eq!(out.messages[1].role.as_str(), "assistant");
assert_eq!(out.messages[2].role.as_str(), "user");
}
/// 正常序列(user 开头)不补占位——零回归。
#[test]
fn openai_ensure_leading_user_normal_unchanged() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
assert_eq!(out.messages[0].role.as_str(), "user");
}
// ---------- 三元组一致性自愈(P0:治 DeepSeek/OpenAI 400) ----------
/// 辅助:取 assistant 消息的 tool_call id 列表(发出去的形态)。
fn openai_tool_call_ids(m: &OpenAiMessage) -> Vec<String> {
m.tool_calls
.as_ref()
.map(|arr| {
arr.iter()
.filter_map(|tc| tc.get("id").and_then(|v| v.as_str()).map(String::from))
.collect()
})
.unwrap_or_default()
}
/// 正常三元组(各 id 闭合)零介入:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
/// 约束铁律:不破正常三元组。
#[test]
fn openai_sanitize_keeps_closed_triplets() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user("查天气"),
ChatMessage::assistant_with_tools(
"调用中",
vec![ToolCall::new("call_a", "get_weather", "{}")],
),
ChatMessage::tool_result("call_a", "晴"),
ChatMessage::assistant_with_tools(
"再查",
vec![ToolCall::new("call_b", "get_weather", "{}")],
),
ChatMessage::tool_result("call_b", "雨"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
// 5 条全保留(正常三元组不剥不丢)。
assert_eq!(out.messages.len(), 5, "正常三元组零介入,不应剥/丢任何消息");
// 两个 assistant 头的 tool_calls 完整保留。
let heads: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "assistant")
.collect();
assert_eq!(openai_tool_call_ids(heads[0]), vec!["call_a".to_string()]);
assert_eq!(openai_tool_call_ids(heads[1]), vec!["call_b".to_string()]);
}
/// 末尾 assistant tool_calls 无 result(残末尾)→ 剥离 tool_calls(保留 assistant 文本)。
/// 防 "insufficient tool messages" 400。
#[test]
fn openai_sanitize_strips_tail_unresolved_tool_calls() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user("查天气"),
ChatMessage::assistant_with_tools(
"调工具但 result 还没回来",
vec![ToolCall::new("call_x", "get_weather", "{}")],
),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
// assistant 保留(content 不丢),但 tool_calls 被剥。
let asst = out
.messages
.iter()
.find(|m| m.role == "assistant")
.expect("assistant 应保留");
assert!(
asst.tool_calls.is_none(),
"未闭合 tool_calls 应被剥离"
);
}
/// orphan tool_result(无配对 assistant tool_calls 头)→ 丢弃。
/// 直构造/DB 残留路径绕过 ContextManager::sanitize_messages 时由本守卫兜底。
/// 防 "Messages with role tool must be a response to a preceding message with tool_calls" 400。
#[test]
fn openai_sanitize_drops_orphan_tool_result_no_head() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user("问"),
// 无头的 orphan tool_result(头被裁剪/丢失)。
ChatMessage::tool_result("orphan_id", "结果"),
ChatMessage::assistant("回复"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
// orphan tool 被丢弃,剩 user + assistant。
let tools: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "tool")
.collect();
assert!(tools.is_empty(), "无配对头的 orphan tool_result 应丢弃, 实际 {:?}", tools);
assert_eq!(out.messages.len(), 2, "应剩 user + assistant");
}
/// assistant tool_calls 剥离后,对应 orphan tool_result 同步丢弃(一致性)。
/// 场景:assistant(tc=[a,b]) → tool(a)(b 的 result 丢失)。旧逻辑因下一条是 tool
/// 不剥 → 发出未闭合 b → 400。新逻辑按 id 精确配对:剥 b(保留 a),tool(a) 保留。
#[test]
fn openai_sanitize_partial_triplet_strips_unresolved_id() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user("问"),
ChatMessage::assistant_with_tools(
"调两工具",
vec![
ToolCall::new("call_a", "tool_a", "{}"),
ToolCall::new("call_b", "tool_b", "{}"),
],
),
// 只回了 call_a,call_b 的 result 丢失。
ChatMessage::tool_result("call_a", "a 结果"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let asst = out
.messages
.iter()
.find(|m| m.role == "assistant")
.expect("assistant 应保留");
// 只保留 call_a(已闭合),剥 call_b(未闭合)。
assert_eq!(
openai_tool_call_ids(asst),
vec!["call_a".to_string()],
"部分闭合头应只留已闭合 call_a, 剥未闭合 call_b"
);
// tool(call_a) 保留(有配对头)。
let tools: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "tool")
.collect();
assert_eq!(tools.len(), 1, "call_a 的 tool_result 应保留");
}
/// 全未闭合三元组:assistant(tc=[a]) 但全程无 tool(a) → 剥 tool_calls,
/// 且不残留任何 orphan tool(本就无 tool 消息)。
#[test]
fn openai_sanitize_fully_unresolved_strips_all() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![
ChatMessage::user("问"),
ChatMessage::assistant_with_tools(
"调工具无结果",
vec![
ToolCall::new("call_y", "tool_y", "{}"),
ToolCall::new("call_z", "tool_z", "{}"),
],
),
ChatMessage::assistant("纯文本续"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let heads: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "assistant")
.collect();
// 第一个 assistant(原含 tool_calls)应被剥空;第二个纯文本不变。
assert!(
heads[0].tool_calls.is_none(),
"全未闭合 tool_calls 应全部剥离"
);
assert!(heads[1].tool_calls.is_none(), "纯文本 assistant 无 tool_calls");
}
/// 无 tool_call_id 的 tool 消息(异常数据)→ 丢弃(发出去必 400)。
#[test]
fn openai_sanitize_drops_tool_without_call_id() {
let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat");
let mut bad_tool = ChatMessage::tool_result("temp", "结果");
bad_tool.tool_call_id = None; // 异常:无 id
let req = CompletionRequest {
model: "deepseek-chat".into(),
messages: vec![ChatMessage::user("问"), bad_tool],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
let tools: Vec<&OpenAiMessage> = out
.messages
.iter()
.filter(|m| m.role == "tool")
.collect();
assert!(
tools.is_empty(),
"无 tool_call_id 的 tool 消息应丢弃, 实际 {:?}", tools
);
}
/// CR-空 id:parse_tool_calls 对空 id 生成 gen_tool_{n} fallback(n 取自全局计数器,
/// 跨轮跨 assistant 严格递增),非空原样。根因:SenseNova 等兼容缺陷 provider 发空
/// tool_call.id,多 tool_call 同 id(空串)致 audit/mod.rs:203 seen_ids 去重只留首个
/// → 所有工具结果路由到首个。
///
/// 断言策略:fallback id 由全局 FALLBACK_ID_COUNTER 决定具体序号,**同进程其他测试先
/// 消费计数器即非 0 起**(非确定性),故不假设具体序号,改断言 prefix + 唯一性 + 透传
/// 无损(对齐 provider.rs:495 helper 单测的 starts_with 模式,2026-08-02 走查修复)。
#[test]
fn openai_parse_tool_calls_empty_id_fallback_unique() {
let calls = vec![
OpenAiToolCallResp {
id: String::new(),
call_type: "function".into(),
function: OpenAiFunctionResp { name: "list_dir".into(), arguments: r#"{"path":"docs"}"#.into() },
},
OpenAiToolCallResp {
id: String::new(),
call_type: "function".into(),
function: OpenAiFunctionResp { name: "list_dir".into(), arguments: r#"{"path":"crates"}"#.into() },
},
OpenAiToolCallResp {
id: "call_abc123".into(),
call_type: "function".into(),
function: OpenAiFunctionResp { name: "read_file".into(), arguments: r#"{"path":"根"}"#.into() },
},
];
let parsed = OpenAICompatProvider::parse_tool_calls(calls);
assert_eq!(parsed.len(), 3);
// 空 id → fallback(prefix=gen_tool_,具体序号由全局计数器决定,非确定性,不断言序号)
assert!(
parsed[0].id.starts_with("gen_tool_"),
"空 fallback 应以 gen_tool_ 开头, got: {}",
parsed[0].id
);
assert!(
parsed[1].id.starts_with("gen_tool_"),
"空 fallback 应以 gen_tool_ 开头, got: {}",
parsed[1].id
);
// 非空 id 原样透传
assert_eq!(parsed[2].id, "call_abc123");
// name/args 透传无损
assert_eq!(parsed[0].function.name, "list_dir");
assert_eq!(parsed[1].function.arguments, r#"{"path":"crates"}"#);
// 关键:所有 id 互异(去重后不丢工具)
let mut ids: Vec<&str> = parsed.iter().map(|c| c.id.as_str()).collect();
ids.sort();
let unique: Vec<&str> = {
let mut u = ids.clone();
u.dedup();
u
};
assert_eq!(ids.len(), unique.len(), "id 应全部唯一,实际 {:?}", ids);
}
/// CR-空 id 流式:SSE chunk 携带 `"id":""`(SenseNova 兼容缺陷)→ ToolCallDelta.id
/// 转为 `gen_stream_{n}` fallback(n 取自全局计数器,跨轮跨 assistant 递增,非 None),
/// 保证下游 accumulate_tool_calls 写入 draft.id 非空。chunk 完全无 id 字段(None)保持
/// None(OpenAI 协议:仅首 chunk 有 id,后续 chunk 无 id 不应覆盖首 chunk 权威 id),
/// 由 agentic 转换点兜底。
///
/// 断言策略:fallback id 具体序号由全局 FALLBACK_ID_COUNTER 决定,**同进程其他测试先
/// 消费计数器即非 0 起**(非确定性),故不假设具体序号,改断言 prefix + 跨 chunk 唯一 +
/// None/非空透传(对齐 provider.rs:530 helper 单测的 starts_with 模式,2026-08-02 走查修复)。
#[test]
fn openai_stream_chunk_empty_id_fallback() {
let mut acc: Option<TokenUsage> = None;
// chunk 1: tool_call index=0, id="" → fallback gen_stream_{n}
let data1 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"list_dir","arguments":"{\"path\":\"docs\"}"}}]}}]}"#;
let c1 = apply_openai_sse(data1, &mut acc);
let tc1 = c1.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert_eq!(tc1.index, 0);
let id1 = tc1.id.as_deref().expect("空 id 应转 fallback(非 None)");
assert!(
id1.starts_with("gen_stream_"),
"空 fallback 应以 gen_stream_ 开头, got: {}",
id1
);
// chunk 2: tool_call index=1, id="" → fallback gen_stream_{n+1}(与 chunk 1 不同,唯一)
let data2 = r#"{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"","type":"function","function":{"name":"read_file","arguments":""}}]}}]}"#;
let c2 = apply_openai_sse(data2, &mut acc);
let tc2 = c2.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
let id2 = tc2.id.as_deref().expect("空 id 应转 fallback(非 None)");
assert!(
id2.starts_with("gen_stream_"),
"空 fallback 应以 gen_stream_ 开头, got: {}",
id2
);
assert_ne!(id1, id2, "两次空 id 的 fallback 应不同(全局计数器递增唯一)");
// chunk 3: tool_call index=0, 无 id 字段(None)→ 保持 None(不覆盖首 chunk)
let data3 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"更多参数"}}]}}]}"#;
let c3 = apply_openai_sse(data3, &mut acc);
let tc3 = c3.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert!(tc3.id.is_none(), "无 id 字段 chunk 应保持 None,不覆盖首 chunk 权威 id");
// chunk 4: tool_call 非空 id → 原样透传
let data4 = r#"{"choices":[{"delta":{"tool_calls":[{"index":2,"id":"call_xyz","type":"function","function":{"name":"write"}}]}}]}"#;
let c4 = apply_openai_sse(data4, &mut acc);
let tc4 = c4.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert_eq!(tc4.id.as_deref(), Some("call_xyz"), "非空 id 原样透传");
}
// ---------- 流式 send 错误分类(谓词纯函数,reqwest::Error 无公开构造器以快照模拟) ----------
/// send 阶段陈旧连接首包 RST(reqwest 形态 is_request=true + 源 ConnectionReset)
/// → connect 类词 → 下游可重试。根治 Display 串无 source 关键词被分 unknown 零重试。
#[test]
fn send_error_connection_reset_is_retryable_class() {
let class = crate::send_error_class(crate::SendErrPreds {
is_request: true,
..Default::default()
});
assert_eq!(class, "connect");
}
/// timeout/connect/decode/body/request 形态各归本类,且类词均须落在下游传输可重试
/// 词表内(镜像 src-tauri stream_recv classify_status_or_class 词表,改任一侧需同步)。
#[test]
fn send_error_transport_classes_are_retryable_words() {
const RETRYABLE_WORDS: &[&str] = &[
"timeout",
"connect",
"transport",
"decoding",
"decode",
"body",
"eof",
"reset",
"broken pipe",
];
let cases = [
crate::SendErrPreds { is_timeout: true, ..Default::default() },
crate::SendErrPreds { is_connect: true, ..Default::default() },
crate::SendErrPreds { is_decode: true, ..Default::default() },
crate::SendErrPreds { is_body: true, ..Default::default() },
crate::SendErrPreds { is_request: true, ..Default::default() },
];
for p in cases {
let class = crate::send_error_class(p);
assert!(
RETRYABLE_WORDS.contains(&class),
"类词 {} 应在传输可重试词表内",
class
);
}
}
/// builder 构造错(请求本体问题,重试同败)→ request 类词,不在词表 → 下游 Fatal,
/// 与 retry::is_reqwest_error_retryable 的 is_builder 排除口径一致。
#[test]
fn send_error_builder_is_fatal_class() {
let class = crate::send_error_class(crate::SendErrPreds {
is_request: true,
is_builder: true,
..Default::default()
});
assert_eq!(class, "request");
}
}