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