修复: AI 对话/工具可靠性(sanitize 三元组 + G2 签名重复 + handshake 不杀 loop + 空 tool_call id 兜底)
治 5 个对话停止/工具失败根因:sanitize 三元组按 id 配对治 400;G2 探索熔断从结果空 改签名重复判定(治误停正常探索);handshake 删越权强杀活 loop(generating 归 guard 单源); 空 tool_call id 兜底 gen_<index>(治 SenseNova 工具结果路由错位)。
This commit is contained in:
@@ -16,7 +16,7 @@ use std::time::Duration;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
|
||||
tool_call_id_or_fallback, CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
|
||||
StreamResult, TokenUsage, ToolCall,
|
||||
};
|
||||
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
|
||||
@@ -549,6 +549,9 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
// content 块中拼接 text,收集 tool_use
|
||||
let mut text = String::new();
|
||||
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||
// CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。
|
||||
// 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。
|
||||
let mut tool_use_idx: usize = 0;
|
||||
for block in resp.content {
|
||||
match block.block_type.as_str() {
|
||||
"text" => {
|
||||
@@ -557,16 +560,20 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let id = match block.id {
|
||||
Some(id) if !id.is_empty() => id,
|
||||
_ => {
|
||||
warn!(
|
||||
name = ?block.name,
|
||||
"Anthropic tool_use 块缺少 id,已跳过(空 id 会回传空 tool_use_id 触发 500)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。
|
||||
// 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用
|
||||
// tool_call_id_or_fallback)。Anthropic 一般非空,此为兼容缺陷兜底。
|
||||
// 不再 warn+continue(continue 会丢工具调用致 LLM 拿不到结果)。
|
||||
let raw_id = block.id.unwrap_or_default();
|
||||
let id = tool_call_id_or_fallback(&raw_id, tool_use_idx, "gen_anthropic");
|
||||
if raw_id.is_empty() {
|
||||
warn!(
|
||||
fallback_id = %id,
|
||||
name = ?block.name,
|
||||
"Anthropic tool_use 块 id 为空,已生成 fallback id(原 continue 跳过会丢工具调用)"
|
||||
);
|
||||
}
|
||||
tool_use_idx += 1;
|
||||
let name = block.name.unwrap_or_default();
|
||||
let args = block
|
||||
.input
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
||||
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// Anthropic API 请求/响应结构体
|
||||
@@ -174,15 +174,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
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());
|
||||
// id 缺失时用占位 id 兜底:流式后续 input_json_delta 按 index 累加,
|
||||
// 中途无法整体跳过;占位 id 保证回传的 tool_use_id 非空,避免 GLM 500。
|
||||
let id = match cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string()) {
|
||||
Some(id) if !id.is_empty() => Some(id),
|
||||
_ => {
|
||||
let placeholder = format!("tool_missing_{}", idx);
|
||||
warn!(%placeholder, name = ?name, "Anthropic 流式 tool_use 块缺少 id,已填占位 id(原样回传会触发 GLM 500)");
|
||||
Some(placeholder)
|
||||
}
|
||||
// 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(),
|
||||
|
||||
@@ -11,7 +11,8 @@ use reqwest::Client;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, StreamResult, TokenUsage, ToolCall,
|
||||
tool_call_id_or_fallback, CompletionRequest, CompletionResponse, LlmProvider, StreamResult,
|
||||
TokenUsage, ToolCall,
|
||||
};
|
||||
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
|
||||
#[cfg(test)]
|
||||
@@ -182,30 +183,25 @@ impl OpenAICompatProvider {
|
||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
// 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息,
|
||||
// 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。
|
||||
// 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。
|
||||
// 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
|
||||
// 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。
|
||||
for i in 0..messages.len() {
|
||||
let role = messages[i].role.clone();
|
||||
if role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
let has_tc = messages[i].tool_calls.is_some();
|
||||
if !has_tc {
|
||||
continue;
|
||||
}
|
||||
let next_is_tool = i + 1 < messages.len()
|
||||
&& matches!(messages[i + 1].role.as_str(), "tool");
|
||||
if !next_is_tool {
|
||||
messages[i].tool_calls = None;
|
||||
tracing::warn!(
|
||||
"[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)",
|
||||
i, role,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 治 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()
|
||||
@@ -304,15 +300,149 @@ impl OpenAICompatProvider {
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析同步响应中的工具调用
|
||||
/// 解析同步响应中的工具调用。
|
||||
///
|
||||
/// 兜底(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()
|
||||
.map(|c| ToolCall::new(c.id, c.function.name, c.function.arguments))
|
||||
.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::warn!(
|
||||
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::warn!(
|
||||
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 通用)
|
||||
@@ -801,4 +931,315 @@ mod tests {
|
||||
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 按 index 生成 gen_tool_{i} fallback,非空原样。
|
||||
/// 根因:SenseNova 等兼容缺陷 provider 发空 tool_call.id,多 tool_call 同 id(空串)
|
||||
/// 致 audit/mod.rs:203 seen_ids 去重只留首个 → 所有工具结果路由到首个。
|
||||
#[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(按 index),保证唯一
|
||||
assert_eq!(parsed[0].id, "gen_tool_0");
|
||||
assert_eq!(parsed[1].id, "gen_tool_1");
|
||||
// 非空 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_{index}` fallback(非 None),保证下游 accumulate_tool_calls 写入
|
||||
/// draft.id 非空。chunk 完全无 id 字段(None)保持 None(OpenAI 协议:仅首 chunk 有 id,
|
||||
/// 后续 chunk 无 id 不应覆盖首 chunk 权威 id),由 agentic 转换点兜底。
|
||||
#[test]
|
||||
fn openai_stream_chunk_empty_id_fallback() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
// chunk 1: tool_call index=0, id="" → fallback gen_stream_0
|
||||
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);
|
||||
assert_eq!(tc1.id.as_deref(), Some("gen_stream_0"), "空 id 应转 fallback");
|
||||
|
||||
// chunk 2: tool_call index=1, id="" → fallback gen_stream_1(与 index=0 不同,唯一)
|
||||
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();
|
||||
assert_eq!(tc2.id.as_deref(), Some("gen_stream_1"), "不同 index 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 原样透传");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
||||
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI API 请求/响应结构体
|
||||
@@ -211,11 +211,20 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
|
||||
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
||||
tcs.into_iter()
|
||||
.map(|tc| ToolCallDelta {
|
||||
index: tc.index,
|
||||
id: tc.id,
|
||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||
.map(|tc| {
|
||||
// CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。
|
||||
// 仅对「provider 显式给了 id 字段」的 chunk 做兜底——None(OpenAI
|
||||
// 协议:仅首 chunk 携带 id,后续 chunk 无 id)保持 None,避免
|
||||
// 覆盖首 chunk 的权威 id。Some("") → `gen_stream_{index}` fallback,
|
||||
// Some(非空) → 原样。下游 stream_recv 按 index 累积,draft.id 透传
|
||||
// 至 ToolCall.id(accumulate_tool_calls 仅 Some 覆盖,None 不动)。
|
||||
let id = tc.id.map(|raw| tool_call_id_or_fallback(&raw, tc.index as usize, "gen_stream"));
|
||||
ToolCallDelta {
|
||||
index: tc.index,
|
||||
id,
|
||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user