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 Other,
79}
80
81impl LlmError {
82 pub fn new(kind: LlmErrorKind, message: impl Into<String>) -> Self {
84 Self {
85 kind,
86 message: message.into(),
87 }
88 }
89
90 pub fn classify(message: impl Into<String>) -> Self {
95 let message = message.into();
96 let kind = Self::classify_message(&message);
97 Self { kind, message }
98 }
99
100 fn classify_message(msg: &str) -> LlmErrorKind {
102 if msg.contains("maximum context length")
104 || msg.contains("context_length_exceeded")
105 || msg.contains("too many tokens")
106 || msg.contains("exceeds the model's context")
107 || msg.contains("reduce the length of the input")
108 || msg.contains("maximum input length")
109 || msg.contains("prompt is too long")
110 || msg.contains("input too long")
111 || msg.contains("input size exceeds")
112 {
113 return LlmErrorKind::ContextLengthExceeded;
114 }
115
116 if msg.contains("failed to parse JSON")
118 || msg.contains("invalid_request_error") && msg.contains("tool")
119 {
120 return LlmErrorKind::MalformedToolCall;
121 }
122
123 if msg.contains("rate_limit") || msg.contains("Rate limit") {
125 return LlmErrorKind::RateLimited;
126 }
127
128 if msg.contains("LLM stream error") || msg.contains("stream failed") {
130 return LlmErrorKind::StreamError;
131 }
132
133 LlmErrorKind::Other
134 }
135
136 pub fn is_recoverable(&self) -> bool {
139 matches!(
140 self.kind,
141 LlmErrorKind::ContextLengthExceeded | LlmErrorKind::MalformedToolCall
142 )
143 }
144}
145
146impl LlmErrorKind {
147 pub fn as_str(&self) -> &'static str {
149 match self {
150 Self::ContextLengthExceeded => "context_length_exceeded",
151 Self::MalformedToolCall => "malformed_tool_call",
152 Self::RateLimited => "rate_limited",
153 Self::StreamError => "stream_error",
154 Self::ContentFiltered => "content_filtered",
155 Self::Other => "other",
156 }
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn classify_context_length_openai() {
166 let err = LlmError::classify(
167 "OpenAI error: API error 400: {\"error\":{\"message\":\"This model's maximum context length is 128000 tokens\",\"type\":\"invalid_request_error\",\"code\":\"context_length_exceeded\"}}",
168 );
169 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
170 assert!(err.is_recoverable());
171 }
172
173 #[test]
174 fn classify_input_size_openrouter() {
175 let err = LlmError::classify(
176 "openrouter error: API error 400 Bad Request: {\"error\":{\"message\":\"The total text input size exceeds 8 MB\",\"code\":400}}",
177 );
178 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
179 }
180
181 #[test]
182 fn classify_context_length_anthropic() {
183 let err = LlmError::classify(
184 "Anthropic error: API error 400: prompt is too long: 200000 tokens > 200000 maximum",
185 );
186 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
187 }
188
189 #[test]
190 fn classify_context_length_too_many_tokens() {
191 let err = LlmError::classify("error: too many tokens in the input");
192 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
193 }
194
195 #[test]
196 fn classify_context_length_reduce_input() {
197 let err = LlmError::classify("Please reduce the length of the input messages");
198 assert_eq!(err.kind, LlmErrorKind::ContextLengthExceeded);
199 }
200
201 #[test]
202 fn classify_malformed_tool_call() {
203 let err = LlmError::classify("failed to parse JSON for tool call arguments");
204 assert_eq!(err.kind, LlmErrorKind::MalformedToolCall);
205 assert!(err.is_recoverable());
206 }
207
208 #[test]
209 fn classify_rate_limited() {
210 let err = LlmError::classify("Rate limit exceeded, please retry after 30s");
211 assert_eq!(err.kind, LlmErrorKind::RateLimited);
212 assert!(!err.is_recoverable());
213 }
214
215 #[test]
216 fn classify_other() {
217 let err = LlmError::classify("Internal server error");
218 assert_eq!(err.kind, LlmErrorKind::Other);
219 assert!(!err.is_recoverable());
220 }
221
222 #[test]
223 fn llm_error_display() {
224 let err = LlmError::new(LlmErrorKind::ContextLengthExceeded, "too long");
225 assert_eq!(err.to_string(), "too long");
226 }
227
228 #[test]
229 fn llm_error_downcast_from_anyhow() {
230 let anyhow_err: anyhow::Error =
231 LlmError::new(LlmErrorKind::ContextLengthExceeded, "test").into();
232 let llm_err = anyhow_err.downcast_ref::<LlmError>().unwrap();
233 assert_eq!(llm_err.kind, LlmErrorKind::ContextLengthExceeded);
234 }
235
236 #[test]
237 fn llm_error_kind_as_str() {
238 assert_eq!(
239 LlmErrorKind::ContextLengthExceeded.as_str(),
240 "context_length_exceeded"
241 );
242 assert_eq!(
243 LlmErrorKind::MalformedToolCall.as_str(),
244 "malformed_tool_call"
245 );
246 assert_eq!(LlmErrorKind::RateLimited.as_str(), "rate_limited");
247 assert_eq!(LlmErrorKind::Other.as_str(), "other");
248 }
249}