Skip to main content

flashmind_types/
error.rs

1//! Error types for the Flashmind framework.
2//!
3//! Contains [`ParseError`] for `FromStr` failures and [`LlmError`] for typed
4//! classification of LLM provider errors.
5
6use thiserror::Error;
7
8// ---------------------------------------------------------------------------
9// ParseError
10// ---------------------------------------------------------------------------
11
12/// A string failed to parse into a typed variant (model ID, ChatKey, etc.).
13///
14/// The public [`String`](#structfield.0) field contains the human-readable error
15/// message produced during parsing.
16///
17/// This type is used as the `Err` for `FromStr` on [`Model`](crate::model::Model),
18/// [`AliasedModel`](crate::model::AliasedModel), and [`Provider`](crate::model::Provider).
19///
20/// # Example
21///
22/// ```ignore
23/// use std::str::FromStr;
24/// use crate::model::Model;
25/// use crate::error::ParseError;
26///
27/// let result = Model::from_str("invalid-format");
28/// assert!(matches!(result, Err(ParseError(_))));
29///
30/// if let Err(ParseError(msg)) = result {
31///     eprintln!("parse failed: {msg}");
32/// }
33/// ```
34#[derive(Debug, Error)]
35#[error("{0}")]
36pub struct ParseError(pub String);
37
38impl ParseError {
39    /// Construct a `ParseError` with the given message.
40    pub fn new(msg: impl Into<String>) -> Self {
41        Self(msg.into())
42    }
43}
44
45// ---------------------------------------------------------------------------
46// LlmError
47// ---------------------------------------------------------------------------
48
49/// Classification of LLM provider errors.
50///
51/// Providers emit this as a concrete error type inside `anyhow::Error` so that
52/// the agent loop can downcast and match on [`LlmErrorKind`] instead of
53/// fragile string matching.
54#[derive(Debug, Error)]
55#[error("{message}")]
56pub struct LlmError {
57    /// Semantic category of the error.
58    pub kind: LlmErrorKind,
59    /// Human-readable error message (typically includes the provider response body).
60    pub message: String,
61}
62
63/// Semantic category for an [`LlmError`].
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum LlmErrorKind {
66    /// The prompt exceeds the model's context window.
67    ContextLengthExceeded,
68    /// The model produced a tool call that could not be parsed (malformed JSON, etc.).
69    MalformedToolCall,
70    /// The provider returned a rate-limit response that was not resolved by retries.
71    RateLimited,
72    /// The response stream failed or ended before the provider confirmed completion.
73    StreamError,
74    /// The provider refused to answer or filtered the response. Retrying the
75    /// same request is pointless, so this error is never retried.
76    ContentFiltered,
77    /// The provider rejected the request's content, such as an attachment it
78    /// cannot read (HTTP 400 or 422). Retrying needs different content.
79    InvalidRequest,
80    /// The credentials, credits, or model setting are wrong (HTTP 401, 402,
81    /// 403 or 404). Retrying cannot help.
82    Configuration,
83    /// Any other provider error that does not fit a specific category.
84    Other,
85}
86
87impl LlmError {
88    /// Create a new `LlmError` with the given kind and message.
89    pub fn new(kind: LlmErrorKind, message: impl Into<String>) -> Self {
90        Self {
91            kind,
92            message: message.into(),
93        }
94    }
95
96    /// Classify an error message string into the appropriate [`LlmErrorKind`].
97    ///
98    /// This inspects the message for well-known patterns returned by LLM APIs
99    /// (OpenAI, Anthropic, Ollama, etc.) and picks the most specific kind.
100    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    /// Determine the [`LlmErrorKind`] for an error message.
107    fn classify_message(msg: &str) -> LlmErrorKind {
108        // Context length patterns from various providers
109        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        // Malformed tool call / JSON parse failure
123        if msg.contains("failed to parse JSON")
124            || msg.contains("invalid_request_error") && msg.contains("tool")
125        {
126            return LlmErrorKind::MalformedToolCall;
127        }
128
129        // Rate limiting (post-retry — send_with_retry already handles transient 429s)
130        if msg.contains("rate_limit") || msg.contains("Rate limit") {
131            return LlmErrorKind::RateLimited;
132        }
133
134        // Stream-level failures (transport errors, connection drops during SSE)
135        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    /// Returns `true` if this error is recoverable through compaction or
149    /// conversation truncation.
150    pub fn is_recoverable(&self) -> bool {
151        matches!(
152            self.kind,
153            LlmErrorKind::ContextLengthExceeded | LlmErrorKind::MalformedToolCall
154        )
155    }
156}
157
158impl LlmErrorKind {
159    /// Human-readable label for this error kind.
160    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
174/// The HTTP status in a provider error message, which the providers write as
175/// `API error 400 Bad Request` or `stream error (400)`.
176fn 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}