修复: 走查发现(空 id 跨轮全局唯一 + generate_image SSRF/OOM + 技能日志 + fallback 测试)

- 空 tool_call id fallback 改全局 AtomicU64 计数器(跨轮跨 assistant 唯一,治空气泡+
  L1 误熔断+三元组错位,实证 af2fab4e);fallback 测试硬编码改 starts_with(解耦计数器)
- generate_image provider POST 补 SSRF 防护(复用 http.rs validate_url/resolve_and_check_host,
  治 base_url 用户可配打内网)+ b64_json 解码前估算长度防 OOM
- SkillResolver 加 tracing 日志(定位技能发送后未注入:name 匹配/read 失败)
This commit is contained in:
lxy
2026-08-02 02:42:02 +08:00
parent 953a3fbba1
commit dffc4e4851
4 changed files with 156 additions and 50 deletions
+42 -13
View File
@@ -1167,9 +1167,14 @@ mod tests {
);
}
/// CR-空 idparse_tool_calls 对空 id 按 index 生成 gen_tool_{i} fallback非空原样。
/// 根因:SenseNova 等兼容缺陷 provider 发空 tool_call.id,多 tool_call 同 id(空串)
/// 致 audit/mod.rs:203 seen_ids 去重只留首个 → 所有工具结果路由到首个。
/// CR-空 idparse_tool_calls 对空 id 生成 gen_tool_{n} fallbackn 取自全局计数器
/// 跨轮跨 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![
@@ -1191,9 +1196,17 @@ mod tests {
];
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 → fallbackprefix=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 透传无损
@@ -1211,24 +1224,40 @@ mod tests {
}
/// CR-空 id 流式:SSE chunk 携带 `"id":""`SenseNova 兼容缺陷)→ ToolCallDelta.id
/// 转为 `gen_stream_{index}` fallback非 None),保证下游 accumulate_tool_calls 写入
/// draft.id 非空。chunk 完全无 id 字段(None)保持 NoneOpenAI 协议:仅首 chunk 有 id
/// 后续 chunk 无 id 不应覆盖首 chunk 权威 id),由 agentic 转换点兜底。
/// 转为 `gen_stream_{n}` fallbackn 取自全局计数器,跨轮跨 assistant 递增,非 None),
/// 保证下游 accumulate_tool_calls 写入 draft.id 非空。chunk 完全无 id 字段(None)保持
/// NoneOpenAI 协议:仅首 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_0
// 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);
assert_eq!(tc1.id.as_deref(), Some("gen_stream_0"), "空 id 应转 fallback");
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_1(与 index=0 不同,唯一)
// 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();
assert_eq!(tc2.id.as_deref(), Some("gen_stream_1"), "不同 index fallback 应不同");
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":"更多参数"}}]}}]}"#;