1use std::fmt;
2
3use thiserror::Error;
4
5#[doc = include_str!("docs/llm_error.md")]
6#[derive(Debug, Error, Clone)]
7pub enum LlmError {
8 #[error("{0} environment variable not set")]
10 MissingApiKey(String),
11 #[error("Failed to create HTTP client: {0}")]
13 HttpClientCreation(String),
14 #[error("{0}")]
17 Provider(#[from] ProviderError),
18 #[error("IO error reading stream: {0}")]
20 IoError(String),
21 #[error("JSON parsing error: {0}")]
23 JsonParsing(String),
24 #[error("Failed to parse tool parameters for {tool_name}: {error}")]
26 ToolParameterParsing { tool_name: String, error: String },
27 #[error("OAuth error: {0}")]
29 OAuthError(String),
30 #[error("Unsupported content: {0}")]
32 UnsupportedContent(String),
33 #[error("Provider '{provider}' requires a URL configured via providers.{provider}.url")]
35 MissingProviderUrl { provider: String },
36 #[error("Unknown provider: {provider}")]
38 UnknownProvider { provider: String },
39 #[error("No models provided")]
41 EmptyModelSpec,
42 #[error("providers.{provider}.{field} cannot be used with multiple {provider} models in one alloy spec")]
46 DuplicateProvider { provider: String, field: String },
47 #[error("Invalid model spec: {0}")]
49 InvalidModelSpec(String),
50 #[error("Failed to build provider request: {0}")]
53 ProviderRequest(String),
54 #[error("Invalid argument: {0}")]
56 InvalidArgument(String),
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ProviderErrorKind {
61 Authentication,
62 Api,
63 RateLimit,
64 Server,
65 Timeout,
66 Network,
67 StreamInterrupted,
68 Unknown,
69}
70
71impl ProviderErrorKind {
72 pub fn is_retryable(&self) -> bool {
73 matches!(
74 self,
75 Self::RateLimit | Self::Server | Self::Timeout | Self::Network | Self::StreamInterrupted | Self::Unknown
76 )
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct ProviderError {
82 pub kind: ProviderErrorKind,
83 pub message: String,
84 pub http_status: Option<u16>,
85 pub request_id: Option<String>,
86 pub code: Option<String>,
87}
88
89impl ProviderError {
90 pub fn new(kind: ProviderErrorKind, message: impl Into<String>) -> Self {
91 Self { kind, message: message.into(), http_status: None, request_id: None, code: None }
92 }
93
94 pub fn authentication(message: impl Into<String>) -> Self {
95 Self::new(ProviderErrorKind::Authentication, message)
96 }
97
98 pub fn api(message: impl Into<String>) -> Self {
99 Self::new(ProviderErrorKind::Api, message)
100 }
101
102 pub fn rate_limit(message: impl Into<String>) -> Self {
103 Self::new(ProviderErrorKind::RateLimit, message)
104 }
105
106 pub fn server(message: impl Into<String>) -> Self {
107 Self::new(ProviderErrorKind::Server, message)
108 }
109
110 pub fn timeout(message: impl Into<String>) -> Self {
111 Self::new(ProviderErrorKind::Timeout, message)
112 }
113
114 pub fn network(message: impl Into<String>) -> Self {
115 Self::new(ProviderErrorKind::Network, message)
116 }
117
118 pub fn stream_interrupted(message: impl Into<String>) -> Self {
119 Self::new(ProviderErrorKind::StreamInterrupted, message)
120 }
121
122 pub fn from_http_status(status: u16, message: impl Into<String>) -> Self {
123 match status {
124 401 | 403 => Self::authentication(message),
125 408 | 504 => Self::timeout(message),
126 429 => Self::rate_limit(message),
127 s if (500..600).contains(&s) => Self::server(message),
128 _ => Self::api(message),
129 }
130 .with_http_status(status)
131 }
132
133 pub fn with_http_status(mut self, status: u16) -> Self {
134 self.http_status = Some(status);
135 self
136 }
137
138 pub fn with_http_metadata(mut self, status: Option<u16>, request_id: Option<String>) -> Self {
139 self.http_status = status;
140 self.request_id = request_id;
141 self
142 }
143
144 pub fn with_code(mut self, code: Option<String>) -> Self {
145 self.code = code;
146 self
147 }
148
149 pub fn with_request_id(mut self, request_id: Option<String>) -> Self {
150 self.request_id = request_id;
151 self
152 }
153
154 pub fn is_retryable(&self) -> bool {
155 self.kind.is_retryable()
156 }
157}
158
159impl fmt::Display for ProviderError {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 let prefix = match self.kind {
162 ProviderErrorKind::Authentication => "Authentication error",
163 ProviderErrorKind::Api => "API error",
164 ProviderErrorKind::RateLimit => "Rate limited",
165 ProviderErrorKind::Server => "Server error",
166 ProviderErrorKind::Timeout => "Request timed out",
167 ProviderErrorKind::Network => "Network error",
168 ProviderErrorKind::StreamInterrupted => "Stream interrupted",
169 ProviderErrorKind::Unknown => "Provider error",
170 };
171 write!(f, "{prefix}: {}", self.message)?;
172 let mut diagnostics = Vec::new();
173 if let Some(status) = self.http_status {
174 diagnostics.push(format!("status {status}"));
175 }
176 if let Some(code) = &self.code {
177 diagnostics.push(format!("code {code}"));
178 }
179 if let Some(request_id) = &self.request_id {
180 diagnostics.push(format!("request_id {request_id}"));
181 }
182 if !diagnostics.is_empty() {
183 write!(f, " ({})", diagnostics.join(", "))?;
184 }
185 Ok(())
186 }
187}
188
189impl std::error::Error for ProviderError {}
190
191impl LlmError {
192 pub fn is_retryable(&self) -> bool {
193 self.provider().is_some_and(ProviderError::is_retryable)
194 }
195
196 pub fn provider(&self) -> Option<&ProviderError> {
197 match self {
198 Self::Provider(error) => Some(error),
199 _ => None,
200 }
201 }
202}
203
204impl From<reqwest::Error> for LlmError {
205 fn from(error: reqwest::Error) -> Self {
206 if error.is_timeout() {
207 return ProviderError::timeout(error.to_string()).into();
208 }
209 if error.is_connect() || error.is_request() {
210 return ProviderError::network(error.to_string()).into();
211 }
212 match error.status().map(|s| s.as_u16()) {
213 Some(status) => ProviderError::from_http_status(status, error.to_string()).into(),
214 None => ProviderError::network(error.to_string()).into(),
215 }
216 }
217}
218
219impl From<serde_json::Error> for LlmError {
220 fn from(error: serde_json::Error) -> Self {
221 LlmError::JsonParsing(error.to_string())
222 }
223}
224
225impl From<std::io::Error> for LlmError {
226 fn from(error: std::io::Error) -> Self {
227 LlmError::IoError(error.to_string())
228 }
229}
230
231impl From<reqwest::header::InvalidHeaderValue> for LlmError {
232 fn from(error: reqwest::header::InvalidHeaderValue) -> Self {
233 LlmError::ProviderRequest(error.to_string())
234 }
235}
236
237impl From<async_openai::error::OpenAIError> for LlmError {
238 fn from(error: async_openai::error::OpenAIError) -> Self {
239 use async_openai::error::OpenAIError;
240 match error {
241 OpenAIError::Reqwest(e) => LlmError::from(e),
242 OpenAIError::StreamError(e) => ProviderError::stream_interrupted(e.to_string()).into(),
243 OpenAIError::ApiError(api_err) => {
244 let status = api_err.status_code.as_u16();
245 let code = api_err.api_error.code.clone();
246 let message = format!("{status} {}", api_err.api_error);
247 ProviderError::from_http_status(status, message).with_code(code).into()
248 }
249 OpenAIError::JSONDeserialize(e, _) => LlmError::JsonParsing(e.to_string()),
250 OpenAIError::FileSaveError(s) | OpenAIError::FileReadError(s) => LlmError::IoError(s),
251 OpenAIError::InvalidArgument(s) => LlmError::InvalidArgument(s),
252 }
253 }
254}
255
256#[cfg(feature = "codex")]
257impl From<aether_auth::OAuthError> for LlmError {
258 fn from(error: aether_auth::OAuthError) -> Self {
259 LlmError::OAuthError(error.to_string())
260 }
261}
262
263pub type Result<T> = std::result::Result<T, LlmError>;
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn retryable_kinds_cover_transient_failures() {
271 assert!(!ProviderErrorKind::Authentication.is_retryable());
272 assert!(!ProviderErrorKind::Api.is_retryable());
273 assert!(ProviderErrorKind::RateLimit.is_retryable());
274 assert!(ProviderErrorKind::Server.is_retryable());
275 assert!(ProviderErrorKind::Timeout.is_retryable());
276 assert!(ProviderErrorKind::Network.is_retryable());
277 assert!(ProviderErrorKind::StreamInterrupted.is_retryable());
278 assert!(ProviderErrorKind::Unknown.is_retryable());
279 }
280
281 #[test]
282 fn http_status_classification_covers_known_statuses() {
283 assert_eq!(ProviderError::from_http_status(401, "d").kind, ProviderErrorKind::Authentication);
284 assert_eq!(ProviderError::from_http_status(403, "d").kind, ProviderErrorKind::Authentication);
285 assert_eq!(ProviderError::from_http_status(408, "d").kind, ProviderErrorKind::Timeout);
286 assert!(ProviderError::from_http_status(408, "d").is_retryable());
287 assert_eq!(ProviderError::from_http_status(429, "d").kind, ProviderErrorKind::RateLimit);
288 assert_eq!(ProviderError::from_http_status(500, "d").kind, ProviderErrorKind::Server);
289 assert_eq!(ProviderError::from_http_status(503, "d").kind, ProviderErrorKind::Server);
290 assert_eq!(ProviderError::from_http_status(400, "d").kind, ProviderErrorKind::Api);
291 assert!(!ProviderError::from_http_status(400, "d").is_retryable());
292 assert_eq!(ProviderError::from_http_status(404, "d").kind, ProviderErrorKind::Api);
293 }
294
295 #[test]
296 fn display_includes_diagnostics_when_present() {
297 let error = ProviderError::server("boom").with_http_status(200).with_code(Some("server_error".into()));
298 let text = error.to_string();
299 assert!(text.contains("boom"));
300 assert!(text.contains("status 200"));
301 assert!(text.contains("code server_error"));
302 let error = error.with_request_id(Some("req-1".into()));
303 assert!(error.to_string().contains("request_id req-1"));
304 }
305
306 #[test]
307 fn display_omits_suffix_when_no_metadata() {
308 let error = ProviderError::api("bad");
309 assert_eq!(error.to_string(), "API error: bad");
310 }
311
312 #[test]
313 fn is_retryable() {
314 assert!(LlmError::from(ProviderError::rate_limit("rl")).is_retryable());
315 assert!(LlmError::from(ProviderError::server("x").with_http_status(503)).is_retryable());
316 assert!(LlmError::from(ProviderError::server("stream-level")).is_retryable());
317 assert!(LlmError::from(ProviderError::timeout("t")).is_retryable());
318 assert!(LlmError::from(ProviderError::network("n")).is_retryable());
319 assert!(LlmError::from(ProviderError::stream_interrupted("s")).is_retryable());
320
321 assert!(!LlmError::from(ProviderError::api("x")).is_retryable());
322 assert!(!LlmError::from(ProviderError::authentication("x")).is_retryable());
323 assert!(!LlmError::MissingApiKey("x".into()).is_retryable());
324 assert!(!LlmError::HttpClientCreation("x".into()).is_retryable());
325 assert!(!LlmError::IoError("x".into()).is_retryable());
326 assert!(!LlmError::JsonParsing("x".into()).is_retryable());
327 assert!(!LlmError::ToolParameterParsing { tool_name: "t".into(), error: "e".into() }.is_retryable());
328 assert!(!LlmError::OAuthError("x".into()).is_retryable());
329 assert!(!LlmError::UnsupportedContent("x".into()).is_retryable());
330 assert!(!LlmError::MissingProviderUrl { provider: "azure-foundry".into() }.is_retryable());
331 assert!(!LlmError::UnknownProvider { provider: "foo".into() }.is_retryable());
332 assert!(!LlmError::EmptyModelSpec.is_retryable());
333 assert!(
334 !LlmError::DuplicateProvider { provider: "bedrock".into(), field: "inferenceProfileArn".into() }
335 .is_retryable()
336 );
337 assert!(!LlmError::InvalidModelSpec("x".into()).is_retryable());
338 assert!(!LlmError::ProviderRequest("x".into()).is_retryable());
339 assert!(!LlmError::InvalidArgument("x".into()).is_retryable());
340 }
341
342 #[test]
343 fn async_openai_api_error_preserves_status_and_code() {
344 use async_openai::error::{ApiError, ApiErrorResponse};
345 let response = ApiErrorResponse {
346 status_code: reqwest::StatusCode::SERVICE_UNAVAILABLE,
347 api_error: ApiError {
348 message: "overloaded".to_string(),
349 r#type: None,
350 param: None,
351 code: Some("server_error".to_string()),
352 },
353 };
354 let error = LlmError::from(async_openai::error::OpenAIError::ApiError(response));
355 let provider = error.provider().expect("expected provider error");
356 assert_eq!(provider.kind, ProviderErrorKind::Server);
357 assert_eq!(provider.http_status, Some(503));
358 assert_eq!(provider.code.as_deref(), Some("server_error"));
359 assert!(error.is_retryable());
360 }
361
362 #[test]
363 fn async_openai_stream_error_is_interruption() {
364 let io = std::io::Error::other("eof");
365 let error = LlmError::from(async_openai::error::OpenAIError::StreamError(Box::new(
366 async_openai::error::StreamError::EventStream(io.to_string()),
367 )));
368 let provider = error.provider().expect("expected provider error");
369 assert_eq!(provider.kind, ProviderErrorKind::StreamInterrupted);
370 assert!(error.is_retryable());
371 }
372}