sqlite_graphrag/chat_api/error.rs
1//! Chat failure type carrying the retry verdict from its origin.
2
3use crate::errors::AppError;
4use crate::retry::AttemptOutcome;
5
6/// [`super::OpenRouterChatClient::complete`] failure (GAP-SG-72-chat /
7/// GAP-SG-72 reauditor addendum).
8///
9/// Wraps the underlying [`AppError`] with whatever truncation diagnostics were
10/// available at the point of failure. `finish_reason`/token fields are `None`
11/// when the failure happened before a response was parsed (network error, a
12/// permanent 4xx, or exhausted retries) — only failures that occur AFTER a
13/// `ChatResponse` was successfully decoded (JSON-repair or shape-guard
14/// failures) carry them.
15///
16/// `retry_class` is the retry verdict computed AT THE ORIGIN (the exact HTTP
17/// status, or the provider's structured error `code`), never inferred
18/// downstream from `source.to_string()`. The enrich queue consumes this field
19/// directly instead of pattern-matching the formatted message.
20#[derive(Debug)]
21pub struct ChatError {
22 /// Underlying cause, preserved via `source()` rather than restated.
23 pub source: AppError,
24 /// `choices[0].finish_reason` from the response that led to this error,
25 /// when one was decoded.
26 pub finish_reason: Option<String>,
27 /// `usage.prompt_tokens` from the response that led to this error, when
28 /// one was decoded.
29 pub prompt_tokens: Option<u32>,
30 /// `usage.completion_tokens` from the response that led to this error,
31 /// when one was decoded.
32 pub completion_tokens: Option<u32>,
33 /// Typed retry verdict computed where the failure originated (HTTP
34 /// status / provider code), not by matching `source`'s message.
35 pub retry_class: AttemptOutcome,
36}
37
38impl ChatError {
39 /// Wraps `source` with no diagnostics attached (used when no
40 /// `ChatResponse` was decoded before the failure) and the `retry_class`
41 /// computed by the caller at the exact HTTP status / provider code.
42 pub(super) fn new(source: AppError, retry_class: AttemptOutcome) -> Self {
43 Self {
44 source,
45 finish_reason: None,
46 prompt_tokens: None,
47 completion_tokens: None,
48 retry_class,
49 }
50 }
51
52 /// Wraps `source` with the diagnostics captured from a decoded
53 /// `ChatResponse` that nonetheless failed downstream (repair or
54 /// shape-guard), plus its `retry_class`.
55 pub(super) fn with_diagnostics(
56 source: AppError,
57 finish_reason: Option<String>,
58 prompt_tokens: Option<u32>,
59 completion_tokens: Option<u32>,
60 retry_class: AttemptOutcome,
61 ) -> Self {
62 Self {
63 source,
64 finish_reason,
65 prompt_tokens,
66 completion_tokens,
67 retry_class,
68 }
69 }
70}
71
72impl std::fmt::Display for ChatError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 std::fmt::Display::fmt(&self.source, f)
75 }
76}
77
78impl std::error::Error for ChatError {
79 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
80 Some(&self.source)
81 }
82}
83
84/// True when an error from `execute_with_retry` indicates the model rejected
85/// `reasoning.enabled=false` because reasoning is mandatory: an HTTP 400 whose
86/// body mentions "reasoning" (case-insensitive). Triggers the one-shot retry
87/// with the `reasoning` field omitted.
88///
89/// This IS a legitimate, narrowly-scoped substring check on the underlying
90/// `AppError`'s message — not a retry-classification decision (that lives in
91/// `ChatError.retry_class`, computed at the origin). It only decides whether
92/// to attempt the mandatory-reasoning fallback shape, an orthogonal concern.
93pub(super) fn reasoning_disable_rejected(err: &ChatError) -> bool {
94 let msg = err.source.to_string().to_lowercase();
95 msg.contains("400") && msg.contains("reasoning")
96}