1#[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 #[error("beta header rejected by API: {header}")]
59 BetaHeaderRejected { header: String },
60
61 #[error("invalid input for {provider}: {message}")]
64 InvalidInput { provider: String, message: String },
65
66 #[error("{0}")]
67 Other(String),
68}
69
70impl LlmError {
71 #[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 #[must_use]
83 pub fn is_beta_header_rejected(&self) -> bool {
84 matches!(self, Self::BetaHeaderRejected { .. })
85 }
86
87 #[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}