修复: OpenAI SSE error 识别 + 条件引擎 warn + MidStream stop_flag 优先 + 双窗口 listener 时序加固

This commit is contained in:
lxy
2026-08-01 14:53:03 +08:00
parent ba578dfe6c
commit db99e7107c
6 changed files with 161 additions and 8 deletions
+82 -1
View File
@@ -231,6 +231,49 @@ impl OpenAICompatProvider {
}
}
/// 生成 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`
@@ -419,6 +462,9 @@ impl LlmProvider for OpenAICompatProvider {
// (严格 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>| {
@@ -426,7 +472,10 @@ impl LlmProvider for OpenAICompatProvider {
match result {
Ok(events) => {
for data in events {
let chunk = apply_openai_sse(&data, &mut last_usage);
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));
}
}
@@ -616,6 +665,38 @@ mod tests {
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 字段应兜底");
}
// ---------- 多模态 convert_request ----------
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写