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#[derive(Debug, thiserror::Error)]
5pub enum LlmError {
6    #[error("HTTP request failed: {0}")]
7    Http(#[from] reqwest::Error),
8
9    #[error("JSON parse failed: {0}")]
10    Json(#[from] serde_json::Error),
11
12    #[error("rate limited")]
13    RateLimited,
14
15    #[error("provider unavailable")]
16    Unavailable,
17
18    #[error("empty response from {provider}")]
19    EmptyResponse { provider: String },
20
21    #[error("SSE parse error: {0}")]
22    SseParse(String),
23
24    #[error("embedding not supported by {provider}")]
25    EmbedUnsupported { provider: String },
26
27    #[error("model loading failed: {0}")]
28    ModelLoad(String),
29
30    #[error("inference failed: {0}")]
31    Inference(String),
32
33    #[error("no route configured")]
34    NoRoute,
35
36    #[error("no providers available")]
37    NoProviders,
38
39    #[cfg(feature = "candle")]
40    #[error("candle error: {0}")]
41    Candle(#[from] candle_core::Error),
42
43    #[error("structured output parse failed: {0}")]
44    StructuredParse(String),
45
46    #[error("transcription failed: {0}")]
47    TranscriptionFailed(String),
48
49    #[error("context length exceeded")]
50    ContextLengthExceeded,
51
52    #[error("LLM request timed out")]
53    Timeout,
54
55    /// A beta header sent in the request was rejected by the API (e.g. `compact-2026-01-12`
56    /// deprecated or not yet available). The provider has already disabled the feature
57    /// internally; the caller should retry without it.
58    #[error("beta header rejected by API: {header}")]
59    BetaHeaderRejected { header: String },
60
61    /// The input itself is invalid (HTTP 400). Retrying with the same input on another
62    /// provider will not help — the router should break the fallback loop immediately.
63    #[error("invalid input for {provider}: {message}")]
64    InvalidInput { provider: String, message: String },
65
66    #[error("{0}")]
67    Other(String),
68}
69
70impl LlmError {
71    /// Returns true if this error indicates the context/prompt is too long for the model.
72    #[must_use]
73    pub fn is_context_length_error(&self) -> bool {
74        match self {
75            Self::ContextLengthExceeded => true,
76            Self::Other(msg) => is_context_length_message(msg),
77            _ => false,
78        }
79    }
80
81    /// Returns true if this error indicates that a beta header was rejected by the API.
82    #[must_use]
83    pub fn is_beta_header_rejected(&self) -> bool {
84        matches!(self, Self::BetaHeaderRejected { .. })
85    }
86
87    /// Returns true if this error indicates that the input itself is invalid (HTTP 400).
88    ///
89    /// Callers (e.g. the router fallback loop) should not retry with a different provider
90    /// when this is true — the same input will fail there too.
91    #[must_use]
92    pub fn is_invalid_input(&self) -> bool {
93        matches!(self, Self::InvalidInput { .. })
94    }
95
96    #[must_use]
97    pub fn is_rate_limited(&self) -> bool {
98        matches!(self, Self::RateLimited)
99    }
100}
101
102fn is_context_length_message(msg: &str) -> bool {
103    let lower = msg.to_lowercase();
104    lower.contains("maximum number of tokens")
105        || lower.contains("context length exceeded")
106        || lower.contains("maximum context length")
107        || lower.contains("context_length_exceeded")
108        || lower.contains("prompt is too long")
109        || lower.contains("input too long")
110}
111
112pub type Result<T> = std::result::Result<T, LlmError>;
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn context_length_exceeded_variant_is_detected() {
120        assert!(LlmError::ContextLengthExceeded.is_context_length_error());
121    }
122
123    #[test]
124    fn other_with_claude_message_is_detected() {
125        let e = LlmError::Other("maximum number of tokens exceeded".into());
126        assert!(e.is_context_length_error());
127    }
128
129    #[test]
130    fn other_with_openai_message_is_detected() {
131        let e = LlmError::Other(
132            "This model's maximum context length is 4096 tokens. context_length_exceeded".into(),
133        );
134        assert!(e.is_context_length_error());
135    }
136
137    #[test]
138    fn other_with_ollama_message_is_detected() {
139        let e = LlmError::Other("context length exceeded for model".into());
140        assert!(e.is_context_length_error());
141    }
142
143    #[test]
144    fn unrelated_error_is_not_detected() {
145        assert!(!LlmError::Unavailable.is_context_length_error());
146        assert!(!LlmError::RateLimited.is_context_length_error());
147        assert!(!LlmError::Other("some unrelated error".into()).is_context_length_error());
148    }
149
150    #[test]
151    fn context_length_exceeded_display() {
152        assert_eq!(
153            LlmError::ContextLengthExceeded.to_string(),
154            "context length exceeded"
155        );
156    }
157
158    #[test]
159    fn beta_header_rejected_is_detected() {
160        let e = LlmError::BetaHeaderRejected {
161            header: "compact-2026-01-12".into(),
162        };
163        assert!(e.is_beta_header_rejected());
164    }
165
166    #[test]
167    fn other_error_is_not_beta_header_rejected() {
168        assert!(!LlmError::Unavailable.is_beta_header_rejected());
169        assert!(!LlmError::ContextLengthExceeded.is_beta_header_rejected());
170        assert!(!LlmError::Other("400 bad request".into()).is_beta_header_rejected());
171    }
172
173    #[test]
174    fn beta_header_rejected_display() {
175        let e = LlmError::BetaHeaderRejected {
176            header: "compact-2026-01-12".into(),
177        };
178        assert!(e.to_string().contains("compact-2026-01-12"));
179    }
180
181    #[test]
182    fn invalid_input_is_detected() {
183        let e = LlmError::InvalidInput {
184            provider: "openai".into(),
185            message: "maximum sequence length exceeded".into(),
186        };
187        assert!(e.is_invalid_input());
188    }
189
190    #[test]
191    fn other_errors_are_not_invalid_input() {
192        assert!(!LlmError::Unavailable.is_invalid_input());
193        assert!(!LlmError::RateLimited.is_invalid_input());
194        assert!(!LlmError::Other("400 bad request".into()).is_invalid_input());
195    }
196
197    #[test]
198    fn invalid_input_display_includes_provider_and_message() {
199        let e = LlmError::InvalidInput {
200            provider: "openai".into(),
201            message: "input too long".into(),
202        };
203        let s = e.to_string();
204        assert!(s.contains("openai"));
205        assert!(s.contains("input too long"));
206    }
207}