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