修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复

This commit is contained in:
lxy
2026-08-12 00:29:06 +08:00
parent 9407f821b9
commit d31a27512c
36 changed files with 2614 additions and 264 deletions
+79 -2
View File
@@ -495,7 +495,7 @@ pub(crate) async fn stream_llm(
if full_text.is_empty() && tool_calls_acc.is_empty() {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
retryable: classify_status_with_body(&status_or_class, &raw),
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
@@ -631,6 +631,34 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
|| lower.contains("broken pipe")
}
/// 429 额度/配额耗尽特征检测:区分「额度耗尽型 429」(确定性,重试必失败)与
/// 「普通瞬时限流 429」(可重试)。实证:anthropic/GLM 周月额度耗尽返回
/// `[1310] 您已达到每周/每月使用上限,限额将在 X 重置`(code 1310)。
/// 特征命中 → Fatal 不重试;未命中 → 交 is_status_retryable 正常判定。
/// 入参 combined:状态码串 + 错误体原文(如 "HTTP 429 [1310] 已达到上限...")。
fn is_quota_exhaustion(combined: &str) -> bool {
let lower = combined.to_lowercase();
let quota_markers = [
"额度", "限额", "达到上限", "使用上限", "重置",
"quota", "limit exceeded", "usage limit", "quota exhausted",
"insufficient_quota",
// 注意:不含 rate_limit_error——它是标准 429 错误类型(普通瞬时限流也带),
// 误配会把可恢复的限流当额度耗尽降 Fatal。额度语义用上面明确词。
];
// 仅当文本同时含「429」与额度特征,才判定额度耗尽(避免误伤普通限流)。
lower.contains("429")
&& quota_markers.iter().any(|m| lower.contains(m))
}
/// 额度耗尽型 429 → Fatal(确定性,不重试);否则交 classify_status_or_class 正常判定。
/// 入参 body:错误体原文(provider bail 文本 / error 帧 message)。
fn classify_status_with_body(status_or_class: &str, body: &str) -> bool {
if is_quota_exhaustion(&format!("{status_or_class} {body}")) {
return false;
}
classify_status_or_class(status_or_class)
}
/// 据 provider 流式 error 帧的 message 文本分类是否可重试(A2-B12 / G4.2)。
///
/// openai/anthropic helper 仅将 error 帧的 `message` 字段透传进 chunk.error
@@ -641,8 +669,10 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
/// - 其余 → retryable=true(保守,防误判瞬态为 Fatal)
fn classify_error_frame_retryable(msg: &str, status_or_class: &str) -> bool {
// 已有状态码/传输类 → 走既有单一分类源(显式 4xx Fatal,5xx/429/timeout/connect 可重试)
// 额度耗尽型 429(如 [1310] 已达周/月上限)确定性失败,经 body 感知识别降 Fatal,
// 不再被普通 429 路径误判可重试空耗。
if status_or_class != "unknown" {
return classify_status_or_class(status_or_class);
return classify_status_with_body(status_or_class, msg);
}
// 无状态码:按错误类型关键词明确非重试签名降级 Fatal
let lower = msg.to_lowercase();
@@ -779,6 +809,53 @@ mod tests {
assert!(classify_error_frame_retryable("stream error", &s2));
}
// ---- is_quota_exhaustion / classify_status_with_body:额度耗尽型 429 不重试 ----
/// 额度耗尽型 429anthropic/GLM 周月上限,含 [1310] 与「使用上限/重置」特征)→ 判定额度耗尽
#[test]
fn quota_exhaustion_429_detected() {
let combined = "HTTP 429 [1310] 您已达到每周/每月使用上限,限额将在 2026-08-14 重置";
assert!(is_quota_exhaustion(combined));
assert!(!classify_status_with_body("HTTP 429", combined));
}
/// 普通瞬时限流 429(无额度特征)→ 不判定额度耗尽,仍可重试
#[test]
fn transient_429_not_quota() {
let combined = "HTTP 429 请求过于频繁,请稍后重试";
assert!(!is_quota_exhaustion(combined));
assert!(classify_status_with_body("HTTP 429", combined));
}
/// 额度关键词但无 429 状态码 → 不判定额度耗尽(防误伤普通 5xx)
#[test]
fn quota_marker_without_429_not_detected() {
assert!(!is_quota_exhaustion("HTTP 500 达到上限"));
}
/// classify_status_with_body 非 429 路径行为不变(5xx 仍可重试)
#[test]
fn classify_status_with_body_5xx_retryable() {
assert!(classify_status_with_body("HTTP 500", "Internal Server Error"));
}
/// 普通瞬时限流 429 的标准 type=rate_limit_error 不应误判额度耗尽(仍可重试,防误伤)
#[test]
fn standard_rate_limit_error_not_quota() {
let combined = r#"HTTP 429 {"type":"rate_limit_error","message":"请求过于频繁,请稍后重试"}"#;
assert!(!is_quota_exhaustion(combined));
assert!(classify_status_with_body("HTTP 429", combined));
}
/// error 帧 message 含额度耗尽型 429 → 经 classify_error_frame_retryable 降 Fatal 不重试
#[test]
fn error_frame_quota_429_fatal() {
let msg = "HTTP 429 [1310] 您已达到每周/每月使用上限,限额将在 2026-08-14 重置";
let (s, _) = extract_error_diag_from_str(msg);
assert_eq!(s, "HTTP 429");
assert!(!classify_error_frame_retryable(msg, &s));
}
// ---- extract_error_diag:业务层 bailprovider 串已含状态码)----
/// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错)