修复: 上下文流式与工具签名域清债
- tool_signature 归一化补参,合法换参不再误触探索熔断 - SSE 超限截头改对齐事件边界保尾,流不再永久破损 - content_filter 纳入终态判定,无 DONE 流也收敛 - sanitize 占位补头前缀短路只吞 orphan 的盲区消除 - intent 关键词加词边界,history 不再误判 - 恢复重放后重置话题锚点,URL 图 token 地板不被稀释
This commit is contained in:
@@ -522,17 +522,23 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
error!(%status, %body, "LLM API 调用失败(不可重试)");
|
||||
return AttemptOutcome::Fatal(msg);
|
||||
}
|
||||
// body 解析: serde 结构错属 Fatal(响应已成功送达,重试同样失败);
|
||||
// body 读取中途断连在 reqwest 同归 is_decode,重发幂等安全 → 可重试。
|
||||
let body: OpenAiResponse = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
// 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 e.is_decode() {
|
||||
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e));
|
||||
if is_reqwest_error_retryable(&e) || e.is_decode() {
|
||||
return AttemptOutcome::Retryable(format!("响应读取失败(可重试): {}", e));
|
||||
}
|
||||
return AttemptOutcome::Fatal(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()),
|
||||
@@ -858,6 +864,39 @@ mod tests {
|
||||
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);纯文本 → 字符串简写
|
||||
|
||||
Reference in New Issue
Block a user