1use thiserror::Error;
7
8#[derive(Debug, Error)]
35#[error("{0}")]
36pub struct ParseError(pub String);
37
38impl ParseError {
39 pub fn new(msg: impl Into<String>) -> Self {
41 Self(msg.into())
42 }
43}
44
45#[derive(Debug, Error)]
55#[error("{message}")]
56pub struct LlmError {
57 pub kind: LlmErrorKind,
59 pub message: String,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum LlmErrorKind {
66 ContextLengthExceeded,
68 MalformedToolCall,
70 RateLimited,
72 StreamError,
74 ContentFiltered,
77 InvalidRequest,
80 Configuration,
83 Other,
85}
86
87impl LlmError {
88 pub fn new(kind: LlmErrorKind, message: impl Into<String>) -> Self {
90 Self {
91 kind,
92 message: message.into(),
93 }
94 }
95
96 pub fn classify(message: impl Into<String>) -> Self {
101 let message = message.into();
102 let kind = Self::classify_message(&message);
103 Self { kind, message }
104 }
105
106 fn classify_message(msg: &str) -> LlmErrorKind {
108 if msg.contains("maximum context length")
110 || msg.contains("context_length_exceeded")
111 || msg.contains("too many tokens")
112 || msg.contains("exceeds the model's context")
113 || msg.contains("reduce the length of the input")
114 || msg.contains("maximum input length")
115 || msg.contains("prompt is too long")
116 || msg.contains("input too long")
117 || msg.contains("input size exceeds")
118 {
119 return LlmErrorKind::ContextLengthExceeded;
120 }
121
122 if msg.contains("failed to parse JSON")
124 || msg.contains("invalid_request_error") && msg.contains("tool")
125 {
126 return LlmErrorKind::MalformedToolCall;
127 }
128
129 if msg.contains("rate_limit") || msg.contains("Rate limit") {
131 return LlmErrorKind::RateLimited;
132 }
133
134 if msg.contains("LLM stream error") || msg.contains("stream failed") {
136 return LlmErrorKind::StreamError;
137 }
138
139 match http_status(msg) {
140 Some(401..=404) => LlmErrorKind::Configuration,
141 Some(413) => LlmErrorKind::ContextLengthExceeded,
142 Some(408 | 429 | 500..=599) => LlmErrorKind::StreamError,
143 Some(400..=499) => LlmErrorKind::InvalidRequest,
144 _ => LlmErrorKind::Other,
145 }
146 }
147
148 pub fn is_recoverable(&self) -> bool {
151 matches!(
152 self.kind,
153 LlmErrorKind::ContextLengthExceeded | LlmErrorKind::MalformedToolCall
154 )
155 }
156}
157
158impl LlmErrorKind {
159 pub fn as_str(&self) -> &'static str {
161 match self {
162 Self::ContextLengthExceeded => "context_length_exceeded",
163 Self::MalformedToolCall => "malformed_tool_call",
164 Self::RateLimited => "rate_limited",
165 Self::StreamError => "stream_error",
166 Self::ContentFiltered => "content_filtered",
167 Self::InvalidRequest => "invalid_request",
168 Self::Configuration => "configuration",
169 Self::Other => "other",
170 }
171 }
172}
173
174fn http_status(msg: &str) -> Option<u16> {
177 msg.match_indices("error").find_map(|(at, word)| {
178 let rest = msg[at + word.len()..].trim_start_matches([' ', '(']);
179 let digits = rest.get(..3)?;
180 let more = rest[3..].starts_with(|c: char| c.is_ascii_digit());
181 if more || !digits.bytes().all(|b| b.is_ascii_digit()) {
182 return None;
183 }
184 digits
185 .parse()
186 .ok()
187 .filter(|status| (400..600).contains(status))
188 })
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn classify_context_length_openai() {
197 let err = LlmError::classify(
198 "OpenAI error: API error 400: {\"error\":{\"message\":\"This model's maximum context length is 128000 tokens\",\"type\":\"invalid_request_error\",\"code\":\"context_length_exceeded\"}}",
199 );
200 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
201 assert!(err.is_recoverable());
202 }
203
204 #[test]
205 fn classify_input_size_openrouter() {
206 let err = LlmError::classify(
207 "openrouter error: API error 400 Bad Request: {\"error\":{\"message\":\"The total text input size exceeds 8 MB\",\"code\":400}}",
208 );
209 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
210 }
211
212 #[test]
213 fn classify_context_length_anthropic() {
214 let err = LlmError::classify(
215 "Anthropic error: API error 400: prompt is too long: 200000 tokens > 200000 maximum",
216 );
217 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
218 }
219
220 #[test]
221 fn classify_context_length_too_many_tokens() {
222 let err = LlmError::classify("error: too many tokens in the input");
223 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
224 }
225
226 #[test]
227 fn classify_context_length_reduce_input() {
228 let err = LlmError::classify("Please reduce the length of the input messages");
229 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
230 }
231
232 #[test]
233 fn classify_malformed_tool_call() {
234 let err = LlmError::classify("failed to parse JSON for tool call arguments");
235 assert_eq!(err.kind, LlmErrorKind::MalformedToolCall);
236 assert!(err.is_recoverable());
237 }
238
239 #[test]
240 fn classify_rate_limited() {
241 let err = LlmError::classify("Rate limit exceeded, please retry after 30s");
242 assert_eq!(err.kind, LlmErrorKind::RateLimited);
243 assert!(!err.is_recoverable());
244 }
245
246 #[test]
247 fn classify_other() {
248 let err = LlmError::classify("Internal server error");
249 assert_eq!(err.kind, LlmErrorKind::Other);
250 assert!(!err.is_recoverable());
251 }
252
253 #[test]
254 fn classify_by_http_status() {
255 for (message, kind) in [
256 (
257 "openrouter error: API error 401 Unauthorized: {}",
258 LlmErrorKind::Configuration,
259 ),
260 (
261 "LLM provider stream error (400): Provider returned error",
262 LlmErrorKind::InvalidRequest,
263 ),
264 (
265 "OpenAI API error 413 Payload Too Large: ",
266 LlmErrorKind::ContextLengthExceeded,
267 ),
268 (
269 "API error 503 Service Unavailable",
270 LlmErrorKind::StreamError,
271 ),
272 ("API error 429 Too Many Requests", LlmErrorKind::StreamError),
273 ("error 4000 things", LlmErrorKind::Other),
274 ] {
275 assert_eq!(LlmError::classify(message).kind, kind, "{message}");
276 }
277 }
278
279 #[test]
280 fn llm_error_display() {
281 let err = LlmError::new(LlmErrorKind::ContextLengthExceeded, "too long");
282 assert_eq!(err.to_string(), "too long");
283 }
284
285 #[test]
286 fn llm_error_downcast_from_anyhow() {
287 let anyhow_err: anyhow::Error =
288 LlmError::new(LlmErrorKind::ContextLengthExceeded, "test").into();
289 let llm_err = anyhow_err.downcast_ref::<LlmError>().unwrap();
290 assert_eq!(llm_err.kind, LlmErrorKind::ContextLengthExceeded);
291 }
292
293 #[test]
294 fn llm_error_kind_as_str() {
295 assert_eq!(
296 LlmErrorKind::ContextLengthExceeded.as_str(),
297 "context_length_exceeded"
298 );
299 assert_eq!(
300 LlmErrorKind::MalformedToolCall.as_str(),
301 "malformed_tool_call"
302 );
303 assert_eq!(LlmErrorKind::RateLimited.as_str(), "rate_limited");
304 assert_eq!(LlmErrorKind::Other.as_str(), "other");
305 }
306}