Files
DevFlow/src-tauri/src/commands/ai/stream_recv.rs

608 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 流式接收 LLM 响应
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use futures::StreamExt;
use tracing::warn;
use df_ai::provider::{CompletionRequest, LlmProvider};
use super::{AiChatEvent, ErrorType, ToolCallDraft};
/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。
///
/// `provider.stream()` 失败有两类来源:
/// - 业务层provider 在 non-2xx 时 `bail!("LLM 流式 API 错误 {status}: {body}")`,原始文本已含状态码,
/// 此处用正则从文本抠 `HTTP <code>` 或裸 `<code>`provider 串已含 status code
/// - 传输层reqwest不直接命名以避免给 src-tauri 加依赖):从 Display 文本识别
/// timeout/connect 关键词分类。
///
/// 返回 `(status_or_class, raw)`status 取文本中首个三位数;否则按关键词给 timeout/connect/unknown。
fn extract_error_diag(e: &anyhow::Error) -> (String, String) {
let raw = e.to_string();
// 1) 抠 HTTP 状态码:匹配 provider bail 串里的 "错误 4xx/5xx" 或 reqwest 的 "HTTP status"。
// R-P2-6:原实现按字节窗口 `&bytes[i..i+3]` 切片——若窗口恰好切在多字节 UTF-8 字符中间会 panic
// (依赖中文恰好 3 字节、状态码恰为 ascii 的巧合)。改为按 char 迭代 + 前后非数字边界判断,
// 既 UTF-8 安全又顺便修复"长数字串(如端口号 14012)内嵌 401 误命中"的潜在问题。
// 白名单码(首位 4/5,故只查 4xx/5xx 区段,减少无效匹配):
const HTTP_CODES: &[&str] = &[
"400", "401", "403", "404", "408", "409", "413",
"422", "429", "500", "502", "503", "504",
];
// 把 raw 按 char 收集,索引即 char 下标(非字节),边界判断用 char 安全
let chars: Vec<char> = raw.chars().collect();
let n = chars.len();
let mut i = 0;
while i + 3 <= n {
// 窗口必须是三个 ascii 数字
if chars[i].is_ascii_digit() && chars[i + 1].is_ascii_digit() && chars[i + 2].is_ascii_digit() {
let code: String = chars[i..i + 3].iter().collect();
// 前后边界必须非数字(否则会从端口号 14012 里抠出 401)
let prev_ok = i == 0 || !chars[i - 1].is_ascii_digit();
let next_ok = i + 3 == n || !chars[i + 3].is_ascii_digit();
if prev_ok && next_ok && HTTP_CODES.contains(&code.as_str()) {
return (format!("HTTP {}", code), raw);
}
i += 3; // 已确认是三连数字,跳过避免窗口重叠重复扫
continue;
}
i += 1;
}
// 2) 传输层分类reqwest Display 文本特征),不命名 reqwest 类型
let lower = raw.to_lowercase();
let class = if lower.contains("timeout") || lower.contains("超时") {
"timeout"
} else if lower.contains("connect") || lower.contains("dns") || lower.contains("resolve") {
"connect"
} else if lower.contains("timed out") {
"timeout"
} else {
"unknown"
};
(class.to_string(), raw)
}
/// AiError 诊断消息的上下文,区分「建连/首字节失败」与「流中途断」两类。
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub(crate) enum DiagKind {
/// `provider.stream()` 直接返回 Err连接/鉴权/HTTP non-2xx 等
Init,
/// 流已建立next() 返回 ErrSSE 传输断/解析错等
MidStream,
}
/// 拼接 AiError 的可读诊断文本(纯函数,便于单测)。
///
/// 格式:`[<provider_name>] <上下文>(<status_or_class>): <raw>`
/// - provider_name`provider.name()`anthropic 协议为 "anthropic-compat"
/// openai 兼容为模型名)。当前 `LlmProvider` trait 未暴露 base_url/provider_type
/// 不改签名的前提下这是唯一可得的 provider 标识。
/// - status_or_class`HTTP 4xx/5xx` 或 `timeout`/`connect`/`unknown` 分类。
/// - rawanyhow 原始错误文本。
pub(crate) fn fmt_diag(provider_name: &str, kind: DiagKind, status_or_class: &str, raw: &str) -> String {
let ctx = match kind {
DiagKind::Init => "AI 调用失败",
DiagKind::MidStream => "流式接收错误",
};
format!("[{}] {}({}): {}", provider_name, ctx, status_or_class, raw)
}
/// 流式接收结果。显式区分三类出口,避免调用方对 None 的歧义推断
/// (UX-2025-04 / CR-30-1 / CR-30-2 / 决策 F-260616-07 a1)。
pub(crate) enum StreamResult {
/// 正常完成(流尽 + finished 信号或用户主动停止。incomplete=false。
/// 调用方按正常流程入库 + emit AiCompleted。
Complete {
text: String,
tool_calls: HashMap<u32, ToolCallDraft>,
usage: df_ai::provider::TokenUsage,
},
/// 流中途断MidStream chunk Err / idle timeout / provider stream-error / 有 partial_text
/// 但流尽未收到 finished。已 emit 过 AiTextDelta前端 currentText 已累积),
/// **不 emit AiError**——调用方保文入库 + emit AiCompleted(incomplete=true) + 系统提示。
/// 调用方**不重试**(决策 a1
Partial {
text: String,
tool_calls: HashMap<u32, ToolCallDraft>,
usage: df_ai::provider::TokenUsage,
},
/// Init 失败provider.stream() Err建连/鉴权/HTTP non-2xx。已 emit AiError。
/// `retryable`: 据状态码分类(retry::is_status_retryable 镜像)true=5xx/429/timeout/connect
/// 可重试false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。
InitFailed { retryable: bool },
}
/// 流式接收 LLM 响应。
///
/// 三类异常处理(返回 StreamResult 显式区分出口):
/// - Init 失败provider.stream() Erremit AiError + 返回 InitFailed{retryable}。
/// retryable 据状态码分类(retry::is_status_retryable 镜像)5xx/429/timeout/connect=true
/// 4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅 retryable=true,≤max_retries 次,
/// 复用 retry::backoff_delay 退避 + 30s 总预算)。
/// - MidStream 失败(流已建立后断/timeout/err 事件/有 partial_text 但流尽未 finished
/// **不 emit AiError**,返回 Partial{...} 保文。调用方不重试,入库 + AiCompleted(incomplete)
/// + 系统提示网络中断。空文本仍 emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
/// - 用户停止stop_flag返回 Completeincomplete 语义非异常中断)。
///
/// 心跳与停止响应B-260615-02 / B-260615-04单 `tokio::select!` 三分支
/// - `stream.next()`:正常 chunk 处理
/// - `heartbeat.tick()`30s静默期如工具执行后等下一轮首 chunkemit `AiHeartbeat`
/// 前端 watchdog 据此 reset区分「LLM 在跑」与「真断」(避免空气泡误报中断)
/// - `stop_notify.notified()`:用户点停止即时打断,不再等 chunk 到或 120s idle timeout
pub(crate) async fn stream_llm(
provider: &dyn LlmProvider,
request: CompletionRequest,
app_handle: &AppHandle,
stop_flag: &AtomicBool,
notify: &tokio::sync::Notify,
conv_id: &str,
) -> StreamResult {
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
/// 心跳间隔静默期向前端报「LLM 仍在跑」reset watchdog
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
match provider.stream(request).await {
Ok(mut stream) => {
let mut full_text = String::new();
let mut tool_calls_acc: HashMap<u32, ToolCallDraft> = HashMap::new();
let mut finished_received = false;
let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
// tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发
// (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 stream.next() 抢分支错过首 chunk)。
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
heartbeat.tick().await;
loop {
// B-260615-04:stop 即时打断。stream.next() 阻塞等 chunk 时,
// 用户点停止需等 chunk 到或 120s idle timeout 才轮到此处检查——
// 合并到下方 select! 的 stop_notify 分支后此处为快路径(非阻塞首检)。
// stop_flag 可能被 stopChat() 在 select! 阻塞期间置位,
// select! 的 stop_notify 分支会唤醒;此处保留作冗余快检(非阻塞)。
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
stopped = true;
break;
}
// 三分支 select!(B-260615-02 + B-260615-04 合并):
// 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断仍 emit AiError)
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
// 3) stop_notify.notified():用户停止即时打断
//
// 注意:stop_flag 是 AtomicBool 无 async 通知能力——
// 此处用「timeout 包 stream.next() + 进入循环前/后查 stop_flag + 循环内 30s 心跳 tick」
// 间接实现「≤30s 感知 stop」(每轮 select! 至多 120s,但心跳 tick 30s 一次会
// 触发 select! 返回 → 循环回顶部 stop_flag 快检)。无 Notify 依赖,改动最小。
tokio::select! {
// 1) 正常 chunk(idle timeout 包裹)
chunk_result = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()) => {
match chunk_result {
Err(_elapsed) => {
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1:
// idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。
// 空文本无文可保,emit AiError + InitFailed{retryable=true}(timeout 瞬态可重试)。
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
error_type: Some(ErrorType::Timeout),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
}
warn!(
provider = %provider.name(),
conv_id = %conv_id,
text_len = full_text.len(),
"[ai] 流中途 idle timeout,保文不重试(incomplete)",
);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
Ok(None) => break, // 流正常结束
Ok(Some(chunk_result)) => match chunk_result {
Ok(chunk) => {
if !chunk.delta.is_empty() {
full_text.push_str(&chunk.delta);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiTextDelta {
delta: chunk.delta,
conversation_id: Some(conv_id.to_string()),
});
}
if let Some(tc_deltas) = &chunk.tool_calls {
for tc_delta in tc_deltas {
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
}
}
if let Some(u) = &chunk.usage {
final_usage = Some(u.clone());
}
// provider 流式错误事件Anthropic SSE `type=="error"` 等):
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
// 不重试。空文本无文可保,emit AiError + InitFailed(保守 retryable=true,
// err_msg 不可靠解析状态码,默认按可重试交调用方决定)。
if let Some(err_msg) = &chunk.error {
warn!(
provider = %provider.name(),
conv_id = %conv_id,
error = %err_msg,
text_len = full_text.len(),
"[ai] provider 流式错误事件",
);
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
"stream-error",
err_msg,
),
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
}
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
if chunk.finished {
finished_received = true;
break;
}
}
Err(e) => {
// 诊断:流中途错误(多为 SSE 传输断),补 provider 标识 + HTTP 状态/分类 + 原始文本
let (status_or_class, raw) = extract_error_diag(&e);
warn!(
provider = %provider.name(),
status = %status_or_class,
conv_id = %conv_id,
error = %raw,
text_len = full_text.len(),
"[ai] 流式接收中途错误",
);
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err——
// 已有文本则保文(Partial),不重试。空文本无文可保,emit AiError +
// InitFailed{retryable=classify_status_or_class(status_or_class)}
// (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
&status_or_class,
&raw,
),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
};
}
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
}
}
}
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
_ = heartbeat.tick() => {
// 心跳只在「仍在等下一 chunk」时有意义——若 stop_flag 已置,顶部快检会 break,无需发心跳
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiHeartbeat {
conversation_id: Some(conv_id.to_string()),
});
}
// 3) 即时停止唤醒(B-260615-14):用户点 stop → ai_chat_stop 置 stop_flag 后 notify_one()。
// stream.next() 正阻塞等 chunk 时立即被唤醒,不再等 30s 心跳 tick 或 120s idle timeout。
// 唤醒后判 stop_flag:真值仍由 AtomicBool 决定,Notify 仅承载「即时唤醒」职责
// (防误唤醒继续跑——若非 stop 触发的 notify,flag 仍 false 则照常进下一轮循环)。
_ = notify.notified() => {
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
stopped = true;
break;
}
}
}
}
// 用户停止已生成文本可能残缺交调用方入库展示。Complete(incomplete=false)——
// 用户主动停止语义非异常中断,正常入库 + AiCompleted(决策 a1: 不重试路径)。
if stopped {
return StreamResult::Complete {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
// 断连检测:流尽但从未收到 finished 信号 = 异常中断。
// B-260615-05:空内容无 finished emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
// UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。
if !finished_received {
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
}
warn!(
provider = %provider.name(),
conv_id = %conv_id,
text_len = full_text.len(),
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
// 正常完成:流尽 + finished 信号到位,Complete(incomplete=false)
StreamResult::Complete {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
}
}
Err(e) => {
// 诊断:连接/鉴权/HTTP 错误,补 provider 标识 + HTTP 状态码/分类 + 原始文本,
// 便于区分 401(key)/404(url)/429(限流)/timeout/连接失败(provider_type 或 base_url 不对)。
let (status_or_class, raw) = extract_error_diag(&e);
warn!(
provider = %provider.name(),
status = %status_or_class,
conv_id = %conv_id,
error = %raw,
"[ai] LLM 流式调用失败",
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: fmt_diag(
provider.name(),
DiagKind::Init,
&status_or_class,
&raw,
),
error_type: None,
conversation_id: Some(conv_id.to_string()),
});
// CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable:
// 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。
StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
}
}
}
}
/// 据 extract_error_diag 产出的 status_or_class 字符串镜像 retry::is_status_retryable 分类。
///
/// status_or_class 取值: "HTTP 5xx"/"HTTP 4xx"(白名单码) / "timeout" / "connect" / "unknown"。
/// - HTTP 429 / 5xx → retryable=true(复用 retry::is_status_retryable 逻辑)
/// - HTTP 4xx(非429) → retryable=false Fatal(401/403/404/422 等)
/// - timeout / connect → retryable=true(瞬态,可重试)
/// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀)
///
/// CR-30-1: 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"),
/// 避免 stream_recv 与 agentic 各自重写状态码解析。
fn classify_status_or_class(status_or_class: &str) -> bool {
use df_ai::retry::is_status_retryable;
// 抠 status_or_class 内三位数字状态码("HTTP 401" → 401)
let chars: Vec<char> = status_or_class.chars().collect();
let mut i = 0;
let n = chars.len();
while i + 3 <= n {
if chars[i].is_ascii_digit()
&& chars[i + 1].is_ascii_digit()
&& chars[i + 2].is_ascii_digit()
{
let code: String = chars[i..i + 3].iter().collect();
if let Ok(status) = code.parse::<u16>() {
return is_status_retryable(status);
}
i += 3;
continue;
}
i += 1;
}
// 无三位数字码:按文本分类(timeout/connect 可重试,unknown/其他保守不重试)
// BUG-260617-01:Anthropic 中文参数错误(如 `[1214][messages 参数非法]`)无 HTTP 数字前缀,
// 走此文本分支,原 || true 致 retryable=true 空耗 4 次重试浪费 token。
// 删 || true 对齐 retry::is_status_retryable 未知码不可重试语义;timeout/connect 瞬态仍可重试。
let lower = status_or_class.to_lowercase();
lower.contains("timeout") || lower.contains("connect") // unknown 保守不重试(BUG-260617-01)
}
// ============================================================
// 单测:诊断提取/格式化(纯函数,不发 HTTP、不依赖 app_handle
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ---- classify_status_or_classCR-30-1 retryable 分类(镜像 retry::is_status_retryable) ----
/// 5xx → retryable=true
#[test]
fn classify_5xx_retryable() {
assert!(classify_status_or_class("HTTP 500"));
assert!(classify_status_or_class("HTTP 503"));
}
/// 429 → retryable=true(限流可重试)
#[test]
fn classify_429_retryable() {
assert!(classify_status_or_class("HTTP 429"));
}
/// 4xx(非429) → retryable=false Fatal(鉴权/参数错立即放弃)
#[test]
fn classify_4xx_fatal() {
assert!(!classify_status_or_class("HTTP 401"));
assert!(!classify_status_or_class("HTTP 403"));
assert!(!classify_status_or_class("HTTP 404"));
assert!(!classify_status_or_class("HTTP 422"));
}
/// timeout / connect → retryable=true(瞬态)
#[test]
fn classify_transient_retryable() {
assert!(classify_status_or_class("timeout"));
assert!(classify_status_or_class("connect"));
}
/// unknown → 保守 retryable=false(BUG-260617-01:防 Anthropic 中文参数错误等 Fatal 空耗重试)
#[test]
fn classify_unknown_not_retryable() {
assert!(!classify_status_or_class("unknown"));
}
// ---- extract_error_diag业务层 bailprovider 串已含状态码)----
/// provider 在 non-2xx bail 的典型串:抠出 401鉴权失败/Key 错)
#[test]
fn diag_extracts_401_from_provider_bail() {
let e = anyhow::anyhow!("LLM 流式 API 错误 401: Unauthorized");
let (status, raw) = extract_error_diag(&e);
assert_eq!(status, "HTTP 401");
assert!(raw.contains("401"));
}
/// 404 = base_url/endpoint 不对
#[test]
fn diag_extracts_404_from_provider_bail() {
let e = anyhow::anyhow!("LLM 流式 API 错误 404: Not Found");
assert_eq!(extract_error_diag(&e).0, "HTTP 404");
}
/// 429 = 限流
#[test]
fn diag_extracts_429_from_provider_bail() {
let e = anyhow::anyhow!("LLM 流式 API 错误 429: rate limit");
assert_eq!(extract_error_diag(&e).0, "HTTP 429");
}
/// 5xx = 上游服务端错
#[test]
fn diag_extracts_500_from_provider_bail() {
let e = anyhow::anyhow!("LLM 流式 API 错误 500: Internal Server Error");
assert_eq!(extract_error_diag(&e).0, "HTTP 500");
}
// ---- extract_error_diag传输层reqwest Display 文本,不命名 reqwest----
/// 超时:连接成功但响应慢/静默断
#[test]
fn diag_classifies_timeout() {
let e = anyhow::anyhow!("error sending request for url (https://api.x.com/v1/chat/completions): operation timed out");
let (status, _) = extract_error_diag(&e);
assert_eq!(status, "timeout");
}
/// 中文「超时」关键词
#[test]
fn diag_classifies_timeout_cn() {
let e = anyhow::anyhow!("请求超时");
assert_eq!(extract_error_diag(&e).0, "timeout");
}
/// 连接失败DNS 解析失败 / 端点不通
#[test]
fn diag_classifies_connect() {
let e = anyhow::anyhow!("dns error: failed to lookup address information");
assert_eq!(extract_error_diag(&e).0, "connect");
}
/// resolve 关键词也归 connect
#[test]
fn diag_classifies_connect_resolve() {
let e = anyhow::anyhow!("error connecting: resolve failed");
assert_eq!(extract_error_diag(&e).0, "connect");
}
// ---- extract_error_diag边界 ----
/// 三位数但不在已知 HTTP 状态白名单(如 "200")→ 不当状态码,走分类
#[test]
fn diag_ignores_non_http_status_number() {
let e = anyhow::anyhow!("成功 200 条记录");
// 200 不在白名单,应落到 unknown
assert_eq!(extract_error_diag(&e).0, "unknown");
}
/// 完全无特征文本 → unknown且 raw 原样返回
#[test]
fn diag_unknown_preserves_raw() {
let e = anyhow::anyhow!("奇怪的错误 xyz");
let (status, raw) = extract_error_diag(&e);
assert_eq!(status, "unknown");
assert_eq!(raw, "奇怪的错误 xyz");
}
/// 状态码出现在更长数字串里也不误匹配(边界判断:前后必须非数字)
#[test]
fn diag_does_not_match_status_inside_longer_digits() {
// R-P2-6:边界判断后,白名单码嵌在长数字串内不再误命中(原窗口切片会从 14012 抠出 401)
let e = anyhow::anyhow!("port 14012 used");
assert_eq!(extract_error_diag(&e).0, "unknown");
// 纯长数字串(无白名单码)同样不命中
let e2 = anyhow::anyhow!("port 99999 used");
assert_eq!(extract_error_diag(&e2).0, "unknown");
}
// ---- fmt_diag两个上下文 ----
#[test]
fn fmt_diag_init_branch() {
let s = fmt_diag("anthropic-compat", DiagKind::Init, "HTTP 401", "Unauthorized");
assert_eq!(s, "[anthropic-compat] AI 调用失败(HTTP 401): Unauthorized");
}
/// R-P2-6:状态码紧贴中文字符(多字节 UTF-8)时仍能正确抠出
/// (原按字节窗口切片在中文边界脆弱,改为 char 迭代后安全)
#[test]
fn diag_extracts_code_adjacent_to_multibyte_chars() {
let e = anyhow::anyhow!("错误401Unauthorized");
assert_eq!(extract_error_diag(&e).0, "HTTP 401");
// 中文在前:状态码前字符是多字节中文,char 迭代边界判断正确
let e2 = anyhow::anyhow!("请求失败401");
assert_eq!(extract_error_diag(&e2).0, "HTTP 401");
}
#[test]
fn fmt_diag_midstream_branch() {
let s = fmt_diag("gpt-4o", DiagKind::MidStream, "timeout", "operation timed out");
assert_eq!(s, "[gpt-4o] 流式接收错误(timeout): operation timed out");
}
/// provider 名/错误文本含特殊字符仍按模板拼接(无格式注入风险)
#[test]
fn fmt_diag_handles_special_chars() {
let s = fmt_diag("model/x", DiagKind::Init, "unknown", "err: {json} \"quoted\"");
assert_eq!(s, "[model/x] AI 调用失败(unknown): err: {json} \"quoted\"");
}
}