修复: 命名空间上限与网络域解析兜底

- 命名空间超容单条拒绝,上限不变式恢复
- usage 部分字段缺失致整帧内容蒸发,补缺省兜底
- tool_call 缺 id 致整批丢弃,对齐流式 fallback 口径
- 视觉模型判定改词素尾,deepseek-v3 不再误判
- 并行分支 panic 任务对账补齐为失败结果
- plan 执行器死代码清理,实况注释校正
This commit is contained in:
lxy
2026-10-03 17:57:33 +08:00
parent 3e3c6f4d0f
commit 8e09112d59
8 changed files with 259 additions and 19 deletions
+65 -1
View File
@@ -102,10 +102,14 @@ pub(crate) struct OpenAiMessageResp {
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiToolCallResp {
/// 缺省容错:个别兼容端点同步响应省略 id → 缺省空串,由 parse_tool_calls 的
/// tool_call_id_or_fallback 生成唯一 fallback。此前必填,单条 tool_call 缺 id 令
/// 整个响应 serde 失败 → complete 报"响应解析失败"整批丢弃(流式路径早已容错)。
#[serde(default)]
pub id: String,
// 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)]
#[serde(rename = "type")]
#[serde(rename = "type", default)]
pub call_type: String,
pub function: OpenAiFunctionResp,
}
@@ -113,6 +117,9 @@ pub(crate) struct OpenAiToolCallResp {
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiFunctionResp {
pub name: String,
/// 缺省容错:arguments 缺失 → 空串(对齐 anthropic 同步 tool_use input 缺失 args="" 口径),
/// 防单条缺字段令整个响应解析失败;name 保持必填(无名 tool_call 无法派发,无安全缺省)。
#[serde(default)]
pub arguments: String,
}
@@ -130,8 +137,13 @@ pub(crate) struct PromptTokensDetails {
/// 嵌套 cached_tokens 回填,消费方一律读归一后的扁平字段。
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiUsageRaw {
/// 三个计数缺省 0:部分兼容网关 usage 只报部分字段(如仅 total_tokens),必填会让
/// 整帧 serde 失败走空 chunk 分支 — 同帧 content 一并蒸发、usage 归零无痕。
#[serde(default)]
pub prompt_tokens: u32,
#[serde(default)]
pub completion_tokens: u32,
#[serde(default)]
pub total_tokens: u32,
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner)。无此字段 → default 0。
#[serde(default)]
@@ -288,6 +300,14 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
}
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
if let Some(u) = chunk.usage {
// 缺省计数的留痕:两个分项均 0 基本可断定 provider 上报不完整(真实完成
// 必有 completion 计数),非流路径下游拿到零 usage 无从察觉。
if u.prompt_tokens == 0 && u.completion_tokens == 0 {
tracing::warn!(
total = u.total_tokens,
"[OpenAI] usage 缺 prompt/completion 分项(按 0 兜底),provider 上报不完整"
);
}
tracing::info!(
prompt = u.prompt_tokens,
completion = u.completion_tokens,
@@ -316,6 +336,21 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
);
let tool_calls = choice.delta.tool_calls.map(|tcs| {
// 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index
// 冲突时缺省会互相踩(下游按 index 累积 → 参数拼错位),warn 留痕定位
// provider 兼容缺陷,不做静默改写。
if tcs.len() > 1 {
let mut idxs: Vec<u32> = tcs.iter().map(|tc| tc.index).collect();
idxs.sort_unstable();
idxs.dedup();
if idxs.len() < tcs.len() {
tracing::warn!(
calls = tcs.len(),
unique = idxs.len(),
"[OpenAI] 同帧多 tool_call 增量 index 冲突(缺省 0 互踩),按 index 累积将错位"
);
}
}
tcs.into_iter()
.map(|tc| {
// CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。
@@ -423,6 +458,35 @@ mod tests {
assert_eq!(usage.prompt_cache_miss_tokens, 36);
}
/// 部分兼容网关 usage 只报部分字段:缺省 0 入累加器,同帧 content 不再因整帧
/// serde 失败被吞(旧行为:必填字段缺失 → 空 chunk → 文本蒸发 + usage 归零无痕)
#[test]
fn sse_partial_usage_frame_parses_with_defaults() {
let mut accum = None;
let data = r#"{"choices":[{"delta":{"content":"hi"}}],"usage":{"total_tokens":10}}"#;
let c = apply_openai_sse(data, &mut accum);
assert_eq!(c.delta, "hi", "usage 缺分项不应令整帧解析失败丢 content");
let u = accum.expect("部分 usage 应写入累加器");
assert_eq!(u.total_tokens, 10);
assert_eq!(u.prompt_tokens, 0, "缺省分项按 0 兜底");
assert_eq!(u.completion_tokens, 0);
}
/// 同帧多 tool_call 增量缺 index:缺省 0 触发 index 冲突路径(仅 warn 留痕,不改写
/// 缺省策略);解析本身不失败,增量仍逐条带出。
#[test]
fn sse_tool_calls_missing_index_in_multi_call_frame_still_parse() {
let mut accum = None;
let data = r#"{"choices":[{"delta":{"tool_calls":[
{"id":"call_a","function":{"name":"ta","arguments":"{"}},
{"id":"call_b","function":{"name":"tb","arguments":"}"}}
]}}]}"#;
let c = apply_openai_sse(data, &mut accum);
let tcs = c.tool_calls.expect("缺 index 的多 call 帧应可解析");
assert_eq!(tcs.len(), 2);
assert!(tcs.iter().all(|tc| tc.index == 0), "缺 index 缺省 0");
}
/// content_filter 属终态:敏感内容拦截的流部分 provider 不发 [DONE] 帧,
/// 必须靠 finish_reason 收敛,否则悬挂;未列入终态的 finish_reason 保持非终态
#[test]