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    /// Any other provider error that does not fit a specific category.
75    Other,
76}
77
78impl LlmError {
79    /// Create a new `LlmError` with the given kind and message.
80    pub fn new(kind: LlmErrorKind, message: impl Into<String>) -> Self {
81        Self {
82            kind,
83            message: message.into(),
84        }
85    }
86
87    /// Classify an error message string into the appropriate [`LlmErrorKind`].
88    ///
89    /// This inspects the message for well-known patterns returned by LLM APIs
90    /// (OpenAI, Anthropic, Ollama, etc.) and picks the most specific kind.
91    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    /// Determine the [`LlmErrorKind`] for an error message.
98    fn classify_message(msg: &str) -> LlmErrorKind {
99        // Context length patterns from various providers
100        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        // Malformed tool call / JSON parse failure
114        if msg.contains("failed to parse JSON")
115            || msg.contains("invalid_request_error") && msg.contains("tool")
116        {
117            return LlmErrorKind::MalformedToolCall;
118        }
119
120        // Rate limiting (post-retry — send_with_retry already handles transient 429s)
121        if msg.contains("rate_limit") || msg.contains("Rate limit") {
122            return LlmErrorKind::RateLimited;
123        }
124
125        // Stream-level failures (transport errors, connection drops during SSE)
126        if msg.contains("LLM stream error") || msg.contains("stream failed") {
127            return LlmErrorKind::StreamError;
128        }
129
130        LlmErrorKind::Other
131    }
132
133    /// Returns `true` if this error is recoverable through compaction or
134    /// conversation truncation.
135    pub fn is_recoverable(&self) -> bool {
136        matches!(
137            self.kind,
138            LlmErrorKind::ContextLengthExceeded | LlmErrorKind::MalformedToolCall
139        )
140    }
141}
142
143impl LlmErrorKind {
144    /// Human-readable label for this error kind.
145    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}