Skip to main content

zeph_llm/
error.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Error type for all LLM provider operations.
5
6/// Errors that can occur in any [`crate::provider::LlmProvider`] operation.
7///
8/// Use the predicate methods ([`is_rate_limited`](Self::is_rate_limited),
9/// [`is_context_length_error`](Self::is_context_length_error),
10/// [`is_invalid_input`](Self::is_invalid_input),
11/// [`is_model_capability_mismatch`](Self::is_model_capability_mismatch),
12/// [`is_beta_header_rejected`](Self::is_beta_header_rejected)) to classify errors
13/// before deciding whether to retry, fall back, or propagate.
14#[non_exhaustive]
15#[derive(Debug, thiserror::Error)]
16pub enum LlmError {
17    /// Underlying HTTP transport error (connection refused, TLS failure, etc.).
18    #[error("HTTP request failed: {0}")]
19    Http(#[from] reqwest::Error),
20
21    /// The API returned a response that could not be decoded as valid JSON.
22    #[error("JSON parse failed: {0}")]
23    Json(#[from] serde_json::Error),
24
25    /// An I/O error occurred (e.g. reading or writing a cache file).
26    #[error("I/O error: {0}")]
27    Io(#[from] std::io::Error),
28
29    /// The provider returned HTTP 429 (too many requests). Callers should back off and retry.
30    #[error("rate limited")]
31    RateLimited,
32
33    /// The provider is temporarily unavailable (HTTP 5xx or connection error).
34    #[error("provider unavailable")]
35    Unavailable,
36
37    /// The provider returned a successful HTTP status but no content in the response body.
38    #[error("empty response from {provider}")]
39    EmptyResponse { provider: String },
40
41    /// A Server-Sent Events frame could not be parsed.
42    #[error("SSE parse error: {0}")]
43    SseParse(String),
44
45    /// [`crate::provider::LlmProvider::embed`] was called on a provider that does not
46    /// support embedding generation.
47    #[error("embedding not supported by {provider}")]
48    EmbedUnsupported { provider: String },
49
50    /// `Candle` model weights or tokenizer could not be loaded from disk or `HuggingFace` Hub.
51    #[error("model loading failed: {0}")]
52    ModelLoad(String),
53
54    /// The `Candle` inference worker returned an error or timed out.
55    #[error("inference failed: {0}")]
56    Inference(String),
57
58    /// The [`crate::router::RouterProvider`] has no providers configured.
59    #[error("no route configured")]
60    NoRoute,
61
62    /// All providers in a router have been exhausted without a successful response.
63    #[error("no providers available")]
64    NoProviders,
65
66    /// A Candle tensor operation failed.
67    #[cfg(feature = "candle")]
68    #[error("candle error: {0}")]
69    Candle(#[from] candle_core::Error),
70
71    /// [`crate::provider::LlmProvider::chat_typed`] could not parse the model's response
72    /// as the requested type, even after a retry.
73    #[error("structured output parse failed: {0}")]
74    StructuredParse(String),
75
76    /// The speech-to-text backend rejected the audio or returned an error.
77    #[error("transcription failed: {0}")]
78    TranscriptionFailed(String),
79
80    /// The prompt exceeds the model's maximum context window. Do not retry with the same input
81    /// on another provider — the same input will fail there too. Summarize or truncate first.
82    #[error("context length exceeded")]
83    ContextLengthExceeded,
84
85    /// The request exceeded the configured per-call timeout.
86    #[error("LLM request timed out")]
87    Timeout,
88
89    /// A beta header sent in the request was rejected by the API (e.g. `compact-2026-01-12`
90    /// deprecated or not yet available). The provider has already disabled the feature
91    /// internally; the caller should retry without it.
92    #[error("beta header rejected by API: {header}")]
93    BetaHeaderRejected { header: String },
94
95    /// The input itself is invalid (HTTP 400). Retrying with the same input on another
96    /// provider will not help — the router should break the fallback loop immediately.
97    #[error("invalid input for {provider}: {message}")]
98    InvalidInput { provider: String, message: String },
99
100    /// The request is well-formed but rejected due to a model- or config-specific
101    /// capability gap (e.g. `reasoning_effort` combined with tool calls on `OpenAI`'s Chat
102    /// Completions API). Unlike [`Self::InvalidInput`], the same request may succeed on a
103    /// different model or provider, so the router should fall back instead of aborting.
104    #[error("model capability mismatch for {provider}: {message}")]
105    ModelCapabilityMismatch { provider: String, message: String },
106
107    /// A provider returned a non-success HTTP status that does not map to any more specific variant.
108    ///
109    /// This covers non-retriable API failures such as authentication errors (401/403),
110    /// server errors (500/503), and unexpected 4xx responses that are not `InvalidInput`,
111    /// `RateLimited`, or `ContextLengthExceeded`. Callers should not retry on this error.
112    #[error("{provider} API request failed (status {status})")]
113    ApiError { provider: String, status: u16 },
114
115    /// Catch-all for provider-specific errors that do not yet have a typed variant.
116    ///
117    /// # Deprecation
118    ///
119    /// Prefer adding a typed variant or propagating a specific source error. This variant
120    /// exists for backward compatibility and will be removed once all callsites are migrated.
121    #[error("{0}")]
122    Other(String),
123}
124
125impl LlmError {
126    /// Returns true if this error indicates the context/prompt is too long for the model.
127    ///
128    /// Providers must return [`LlmError::ContextLengthExceeded`] directly; this predicate
129    /// does not inspect error message strings.
130    #[must_use]
131    pub fn is_context_length_error(&self) -> bool {
132        matches!(self, Self::ContextLengthExceeded)
133    }
134
135    /// Returns true if this error indicates that a beta header was rejected by the API.
136    #[must_use]
137    pub fn is_beta_header_rejected(&self) -> bool {
138        matches!(self, Self::BetaHeaderRejected { .. })
139    }
140
141    /// Returns true if this error indicates that the input itself is invalid (HTTP 400).
142    ///
143    /// Callers (e.g. the router fallback loop) should not retry with a different provider
144    /// when this is true — the same input will fail there too.
145    #[must_use]
146    pub fn is_invalid_input(&self) -> bool {
147        matches!(self, Self::InvalidInput { .. })
148    }
149
150    /// Returns true if this error indicates a model- or config-specific capability gap.
151    ///
152    /// Unlike [`Self::is_invalid_input`], callers (e.g. the router fallback loop) should
153    /// retry with another provider when this is true — the same request may succeed
154    /// elsewhere (different model, or without the offending config option).
155    #[must_use]
156    pub fn is_model_capability_mismatch(&self) -> bool {
157        matches!(self, Self::ModelCapabilityMismatch { .. })
158    }
159
160    #[must_use]
161    pub fn is_rate_limited(&self) -> bool {
162        matches!(self, Self::RateLimited)
163    }
164}
165
166/// Check whether a raw API error body text indicates a context-length error.
167///
168/// Used at the provider transport layer to convert HTTP 400 bodies into
169/// [`LlmError::ContextLengthExceeded`] before the error reaches callers.
170pub(crate) fn body_is_context_length_error(body: &str) -> bool {
171    let lower = body.to_lowercase();
172    lower.contains("maximum number of tokens")
173        || lower.contains("context length exceeded")
174        || lower.contains("maximum context length")
175        || lower.contains("context_length_exceeded")
176        || lower.contains("prompt is too long")
177        || lower.contains("input too long")
178}
179
180/// Check whether a raw 400 body indicates `OpenAI`'s `reasoning_effort` + `tools`
181/// incompatibility on the Chat Completions endpoint (requires `/v1/responses`, which
182/// Zeph does not implement).
183pub(crate) fn body_is_reasoning_effort_tools_incompatible(body: &str) -> bool {
184    let lower = body.to_lowercase();
185    lower.contains("reasoning_effort")
186        && lower.contains("not supported")
187        && (lower.contains("/v1/responses") || lower.contains("responses instead"))
188}
189
190pub type Result<T> = std::result::Result<T, LlmError>;
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn context_length_exceeded_variant_is_detected() {
198        assert!(LlmError::ContextLengthExceeded.is_context_length_error());
199    }
200
201    #[test]
202    fn other_variant_is_not_context_length_error() {
203        // The `Other` path no longer triggers context-length classification.
204        // Providers must return `ContextLengthExceeded` directly.
205        assert!(
206            !LlmError::Other("maximum number of tokens exceeded".into()).is_context_length_error()
207        );
208        assert!(
209            !LlmError::Other("context length exceeded for model".into()).is_context_length_error()
210        );
211    }
212
213    #[test]
214    fn unrelated_error_is_not_detected() {
215        assert!(!LlmError::Unavailable.is_context_length_error());
216        assert!(!LlmError::RateLimited.is_context_length_error());
217        assert!(!LlmError::Other("some unrelated error".into()).is_context_length_error());
218    }
219
220    #[test]
221    fn context_length_exceeded_display() {
222        assert_eq!(
223            LlmError::ContextLengthExceeded.to_string(),
224            "context length exceeded"
225        );
226    }
227
228    #[test]
229    fn beta_header_rejected_is_detected() {
230        let e = LlmError::BetaHeaderRejected {
231            header: "compact-2026-01-12".into(),
232        };
233        assert!(e.is_beta_header_rejected());
234    }
235
236    #[test]
237    fn other_error_is_not_beta_header_rejected() {
238        assert!(!LlmError::Unavailable.is_beta_header_rejected());
239        assert!(!LlmError::ContextLengthExceeded.is_beta_header_rejected());
240        assert!(!LlmError::Other("400 bad request".into()).is_beta_header_rejected());
241    }
242
243    #[test]
244    fn beta_header_rejected_display() {
245        let e = LlmError::BetaHeaderRejected {
246            header: "compact-2026-01-12".into(),
247        };
248        assert!(e.to_string().contains("compact-2026-01-12"));
249    }
250
251    #[test]
252    fn invalid_input_is_detected() {
253        let e = LlmError::InvalidInput {
254            provider: "openai".into(),
255            message: "maximum sequence length exceeded".into(),
256        };
257        assert!(e.is_invalid_input());
258    }
259
260    #[test]
261    fn other_errors_are_not_invalid_input() {
262        assert!(!LlmError::Unavailable.is_invalid_input());
263        assert!(!LlmError::RateLimited.is_invalid_input());
264        assert!(!LlmError::Other("400 bad request".into()).is_invalid_input());
265    }
266
267    #[test]
268    fn invalid_input_display_includes_provider_and_message() {
269        let e = LlmError::InvalidInput {
270            provider: "openai".into(),
271            message: "input too long".into(),
272        };
273        let s = e.to_string();
274        assert!(s.contains("openai"));
275        assert!(s.contains("input too long"));
276    }
277
278    #[test]
279    fn model_capability_mismatch_is_detected() {
280        let e = LlmError::ModelCapabilityMismatch {
281            provider: "openai".into(),
282            message: "reasoning_effort incompatible with tools".into(),
283        };
284        assert!(e.is_model_capability_mismatch());
285        assert!(!e.is_invalid_input());
286    }
287
288    #[test]
289    fn other_errors_are_not_model_capability_mismatch() {
290        assert!(!LlmError::Unavailable.is_model_capability_mismatch());
291        assert!(!LlmError::RateLimited.is_model_capability_mismatch());
292        assert!(
293            !LlmError::InvalidInput {
294                provider: "openai".into(),
295                message: "bad request".into(),
296            }
297            .is_model_capability_mismatch()
298        );
299    }
300
301    #[test]
302    fn model_capability_mismatch_display_includes_provider_and_message() {
303        let e = LlmError::ModelCapabilityMismatch {
304            provider: "openai".into(),
305            message: "reasoning_effort incompatible with tools".into(),
306        };
307        let s = e.to_string();
308        assert!(s.contains("openai"));
309        assert!(s.contains("reasoning_effort incompatible with tools"));
310    }
311
312    #[test]
313    fn api_error_display() {
314        let e = LlmError::ApiError {
315            provider: "claude".into(),
316            status: 503,
317        };
318        let s = e.to_string();
319        assert!(s.contains("claude"));
320        assert!(s.contains("503"));
321    }
322
323    #[test]
324    fn body_is_context_length_error_detects_known_messages() {
325        assert!(body_is_context_length_error(
326            "maximum number of tokens exceeded"
327        ));
328        assert!(body_is_context_length_error(
329            "This model's maximum context length is 4096 tokens. context_length_exceeded"
330        ));
331        assert!(body_is_context_length_error(
332            "context length exceeded for model"
333        ));
334        assert!(body_is_context_length_error("prompt is too long"));
335        assert!(body_is_context_length_error(
336            "input too long for this model"
337        ));
338    }
339
340    #[test]
341    fn body_is_context_length_error_ignores_unrelated_messages() {
342        assert!(!body_is_context_length_error("some unrelated error"));
343        assert!(!body_is_context_length_error("rate limit exceeded"));
344        assert!(!body_is_context_length_error("authentication failed"));
345    }
346
347    #[test]
348    fn body_is_reasoning_effort_tools_incompatible_detects_known_message() {
349        assert!(body_is_reasoning_effort_tools_incompatible(
350            "Function tools with reasoning_effort are not supported for gpt-5.4-mini in \
351             /v1/chat/completions. Please use /v1/responses instead."
352        ));
353    }
354
355    #[test]
356    fn body_is_reasoning_effort_tools_incompatible_ignores_unrelated_messages() {
357        assert!(!body_is_reasoning_effort_tools_incompatible(
358            "rate limit exceeded, please retry later"
359        ));
360        assert!(!body_is_reasoning_effort_tools_incompatible(
361            "invalid request: missing required parameter 'model'"
362        ));
363        assert!(!body_is_reasoning_effort_tools_incompatible(
364            "This model's maximum context length is 4096 tokens. context_length_exceeded"
365        ));
366    }
367
368    #[test]
369    fn body_is_reasoning_effort_tools_incompatible_is_case_insensitive() {
370        assert!(body_is_reasoning_effort_tools_incompatible(
371            "Function tools with REASONING_EFFORT are NOT SUPPORTED for gpt-5.4-mini in \
372             /V1/CHAT/COMPLETIONS. Please use /V1/RESPONSES instead."
373        ));
374    }
375
376    #[test]
377    fn body_is_reasoning_effort_tools_incompatible_requires_all_markers() {
378        // Mentions reasoning_effort and the responses endpoint, but not "not supported" —
379        // should not match, since this isn't necessarily the incompatibility error.
380        assert!(!body_is_reasoning_effort_tools_incompatible(
381            "reasoning_effort was applied; see /v1/responses for details"
382        ));
383        // Mentions "not supported" and the responses endpoint, but never reasoning_effort.
384        assert!(!body_is_reasoning_effort_tools_incompatible(
385            "tool_choice is not supported on /v1/responses for this model"
386        ));
387        // Mentions reasoning_effort and "not supported", but no responses-endpoint pointer.
388        assert!(!body_is_reasoning_effort_tools_incompatible(
389            "reasoning_effort is not supported for this model"
390        ));
391    }
392}