1#[non_exhaustive]
15#[derive(Debug, thiserror::Error)]
16pub enum LlmError {
17 #[error("HTTP request failed: {0}")]
19 Http(#[from] reqwest::Error),
20
21 #[error("JSON parse failed: {0}")]
23 Json(#[from] serde_json::Error),
24
25 #[error("I/O error: {0}")]
27 Io(#[from] std::io::Error),
28
29 #[error("rate limited")]
31 RateLimited,
32
33 #[error("provider unavailable")]
35 Unavailable,
36
37 #[error("empty response from {provider}")]
39 EmptyResponse { provider: String },
40
41 #[error("SSE parse error: {0}")]
43 SseParse(String),
44
45 #[error("embedding not supported by {provider}")]
48 EmbedUnsupported { provider: String },
49
50 #[error("model loading failed: {0}")]
52 ModelLoad(String),
53
54 #[error("inference failed: {0}")]
56 Inference(String),
57
58 #[error("no route configured")]
60 NoRoute,
61
62 #[error("no providers available")]
64 NoProviders,
65
66 #[cfg(feature = "candle")]
68 #[error("candle error: {0}")]
69 Candle(#[from] candle_core::Error),
70
71 #[error("structured output parse failed: {0}")]
74 StructuredParse(String),
75
76 #[error("transcription failed: {0}")]
78 TranscriptionFailed(String),
79
80 #[error("context length exceeded")]
83 ContextLengthExceeded,
84
85 #[error("LLM request timed out")]
87 Timeout,
88
89 #[error("beta header rejected by API: {header}")]
93 BetaHeaderRejected { header: String },
94
95 #[error("invalid input for {provider}: {message}")]
98 InvalidInput { provider: String, message: String },
99
100 #[error("model capability mismatch for {provider}: {message}")]
105 ModelCapabilityMismatch { provider: String, message: String },
106
107 #[error("{provider} API request failed (status {status})")]
113 ApiError { provider: String, status: u16 },
114
115 #[error("{0}")]
122 Other(String),
123}
124
125impl LlmError {
126 #[must_use]
131 pub fn is_context_length_error(&self) -> bool {
132 matches!(self, Self::ContextLengthExceeded)
133 }
134
135 #[must_use]
137 pub fn is_beta_header_rejected(&self) -> bool {
138 matches!(self, Self::BetaHeaderRejected { .. })
139 }
140
141 #[must_use]
146 pub fn is_invalid_input(&self) -> bool {
147 matches!(self, Self::InvalidInput { .. })
148 }
149
150 #[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
166pub(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
180pub(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 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 assert!(!body_is_reasoning_effort_tools_incompatible(
381 "reasoning_effort was applied; see /v1/responses for details"
382 ));
383 assert!(!body_is_reasoning_effort_tools_incompatible(
385 "tool_choice is not supported on /v1/responses for this model"
386 ));
387 assert!(!body_is_reasoning_effort_tools_incompatible(
389 "reasoning_effort is not supported for this model"
390 ));
391 }
392}