1pub type LlmResult<T> = std::result::Result<T, LlmError>;
5
6#[derive(Debug, thiserror::Error)]
8pub enum LlmError {
9 #[error("configuration error: {0}")]
11 Config(String),
12
13 #[error("authentication error: {0}")]
15 Auth(String),
16
17 #[error("rate limited: {message}")]
19 RateLimited {
20 message: String,
21 retry_after_secs: Option<u64>,
23 },
24
25 #[error("timeout: {0}")]
27 Timeout(String),
28
29 #[error("model not found: {0}")]
31 ModelNotFound(String),
32
33 #[error("content filtered: {0}")]
35 ContentFiltered(String),
36
37 #[error("token limit exceeded: {0}")]
39 TokenLimitExceeded(String),
40
41 #[error("provider error ({status}): {message}")]
43 Provider { status: u16, message: String },
44
45 #[error("connection error: {0}")]
47 Connection(String),
48
49 #[error("serialization error: {0}")]
51 Serialization(String),
52
53 #[error("transient error: {0}")]
55 Transient(String),
56
57 #[error("internal error: {0}")]
59 Internal(String),
60}
61
62impl LlmError {
63 pub fn timeout(duration: std::time::Duration) -> Self {
65 let secs = duration.as_secs();
66 let ms = duration.subsec_millis();
67 if secs >= 60 {
68 Self::Timeout(format!("{}m{}s", secs / 60, secs % 60))
69 } else {
70 Self::Timeout(format!("{secs}.{ms:03}s"))
71 }
72 }
73
74 pub fn is_retryable(&self) -> bool {
76 matches!(
77 self,
78 LlmError::RateLimited { .. }
79 | LlmError::Timeout(_)
80 | LlmError::Connection(_)
81 | LlmError::Transient(_)
82 )
83 }
84
85 pub fn is_auth(&self) -> bool {
87 matches!(self, LlmError::Auth(_))
88 }
89
90 pub fn is_rate_limited(&self) -> bool {
92 matches!(self, LlmError::RateLimited { .. })
93 }
94
95 pub fn retry_after_secs(&self) -> Option<u64> {
97 match self {
98 LlmError::RateLimited {
99 retry_after_secs, ..
100 } => *retry_after_secs,
101 _ => None,
102 }
103 }
104
105 pub fn truncated_message(&self, max_len: usize) -> String {
107 let msg = self.to_string();
108 if msg.len() <= max_len {
109 msg
110 } else {
111 let boundary = msg
114 .char_indices()
115 .take_while(|(i, _)| *i <= max_len)
116 .last()
117 .map(|(i, _)| i)
118 .unwrap_or(0);
119 format!("{}...[truncated]", &msg[..boundary])
120 }
121 }
122}
123
124#[cfg(feature = "openai")]
125impl From<reqwest::Error> for LlmError {
126 fn from(err: reqwest::Error) -> Self {
127 if err.is_timeout() {
128 LlmError::Timeout("request timed out".to_string())
129 } else if err.is_connect() {
130 LlmError::Connection(err.to_string())
131 } else if let Some(status) = err.status() {
132 match status.as_u16() {
133 401 | 403 => LlmError::Auth(err.to_string()),
134 429 => LlmError::RateLimited {
135 message: err.to_string(),
136 retry_after_secs: None,
137 },
138 _ => LlmError::Provider {
139 status: status.as_u16(),
140 message: err.to_string(),
141 },
142 }
143 } else {
144 LlmError::Connection(err.to_string())
145 }
146 }
147}
148
149impl From<serde_json::Error> for LlmError {
150 fn from(err: serde_json::Error) -> Self {
151 LlmError::Serialization(err.to_string())
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn test_is_retryable() {
161 assert!(LlmError::RateLimited {
162 message: "slow down".into(),
163 retry_after_secs: None,
164 }
165 .is_retryable());
166 assert!(LlmError::Timeout("t".into()).is_retryable());
167 assert!(LlmError::Connection("c".into()).is_retryable());
168 assert!(LlmError::Transient("t".into()).is_retryable());
169 assert!(!LlmError::Auth("a".into()).is_retryable());
170 assert!(!LlmError::Config("c".into()).is_retryable());
171 }
172
173 #[test]
174 fn test_is_auth() {
175 assert!(LlmError::Auth("bad key".into()).is_auth());
176 assert!(!LlmError::Config("x".into()).is_auth());
177 }
178
179 #[test]
180 fn test_is_rate_limited() {
181 assert!(LlmError::RateLimited {
182 message: "x".into(),
183 retry_after_secs: Some(5),
184 }
185 .is_rate_limited());
186 assert!(!LlmError::Timeout("x".into()).is_rate_limited());
187 }
188
189 #[test]
190 fn test_retry_after_secs() {
191 let err = LlmError::RateLimited {
192 message: "x".into(),
193 retry_after_secs: Some(42),
194 };
195 assert_eq!(err.retry_after_secs(), Some(42));
196
197 let err2 = LlmError::Config("x".into());
198 assert_eq!(err2.retry_after_secs(), None);
199 }
200
201 #[test]
202 fn test_truncated_message_short() {
203 let err = LlmError::Config("short".into());
204 let msg = err.truncated_message(1000);
205 assert_eq!(msg, "configuration error: short");
206 }
207
208 #[test]
209 fn test_truncated_message_long() {
210 let long = "a".repeat(500);
211 let err = LlmError::Config(long);
212 let msg = err.truncated_message(50);
213 assert!(msg.len() < 80); assert!(msg.ends_with("...[truncated]"));
215 }
216
217 #[test]
218 fn test_truncated_message_utf8_boundary() {
219 let emoji_msg = "🔒".repeat(20); let err = LlmError::Config(emoji_msg);
222 let msg = err.truncated_message(26);
226 assert!(msg.ends_with("...[truncated]"));
227 assert!(msg.is_char_boundary(0));
229 }
230
231 #[test]
232 fn test_truncated_message_zero() {
233 let err = LlmError::Config("hello".into());
234 let msg = err.truncated_message(0);
235 assert!(msg.ends_with("...[truncated]"));
236 }
237
238 #[test]
239 fn test_timeout_display() {
240 let err = LlmError::timeout(std::time::Duration::from_millis(1500));
241 assert_eq!(err.to_string(), "timeout: 1.500s");
242 }
243
244 #[test]
245 fn test_timeout_display_minutes() {
246 let err = LlmError::timeout(std::time::Duration::from_secs(125));
247 assert_eq!(err.to_string(), "timeout: 2m5s");
248 }
249}