1use serde::{Deserialize, Serialize};
7use std::fmt;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UserFacingError {
12 pub summary: String,
14 pub message: String,
16 pub suggestion: String,
18 pub category: ErrorCategory,
20 pub recoverable: bool,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ErrorCategory {
27 Connection,
29 Auth,
31 Config,
33 NotFound,
35 Temporary,
37 Internal,
39}
40
41#[derive(Debug)]
43pub enum ModelError {
44 Backend(BackendError),
46
47 Config(ConfigError),
49
50 ModelNotFound {
52 model: String,
53 searched: Vec<String>,
54 },
55
56 Timeout {
58 operation: String,
59 duration_secs: u64,
60 },
61
62 RateLimit { retry_after: Option<u64> },
64
65 InvalidRequest(String),
67
68 ParseError {
70 message: String,
71 raw: Option<String>,
72 },
73
74 StreamError(String),
76
77 Authentication(String),
79
80 Unsupported { feature: String },
84}
85
86impl fmt::Display for ModelError {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 match self {
89 ModelError::Backend(e) => write!(f, "Backend error: {}", e),
90 ModelError::Config(e) => write!(f, "Configuration error: {}", e),
91 ModelError::ModelNotFound { model, searched } => {
92 write!(
93 f,
94 "Model '{}' not found. Searched: {}",
95 model,
96 searched.join(", ")
97 )
98 },
99 ModelError::Timeout {
100 operation,
101 duration_secs,
102 } => {
103 if *duration_secs == 0 {
104 write!(f, "Operation '{}' timed out", operation)
105 } else {
106 write!(
107 f,
108 "Operation '{}' timed out after {} seconds",
109 operation, duration_secs
110 )
111 }
112 },
113 ModelError::RateLimit { retry_after } => {
114 if let Some(secs) = retry_after {
115 write!(f, "Rate limit exceeded. Retry after {} seconds", secs)
116 } else {
117 write!(f, "Rate limit exceeded")
118 }
119 },
120 ModelError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
121 ModelError::ParseError { message, raw } => {
122 if let Some(r) = raw {
123 write!(f, "Parse error: {} (raw: {})", message, r)
124 } else {
125 write!(f, "Parse error: {}", message)
126 }
127 },
128 ModelError::StreamError(msg) => write!(f, "Stream error: {}", msg),
129 ModelError::Authentication(msg) => write!(f, "Authentication error: {}", msg),
130 ModelError::Unsupported { feature } => {
131 write!(f, "Feature not supported by this adapter: {}", feature)
132 },
133 }
134 }
135}
136
137impl std::error::Error for ModelError {}
138
139impl ModelError {
140 pub fn to_user_facing(&self) -> UserFacingError {
142 match self {
143 ModelError::Backend(BackendError::ConnectionFailed { backend, url, .. }) => {
144 UserFacingError {
145 summary: format!("{} connection failed", backend),
146 message: format!("Could not connect to {} at {}", backend, url),
147 suggestion: if backend == "ollama" {
148 "Run 'ollama serve' to start Ollama, or check if it's running on the correct port".to_string()
149 } else {
150 format!("Check if {} is running and accessible", backend)
151 },
152 category: ErrorCategory::Connection,
153 recoverable: true,
154 }
155 },
156 ModelError::Backend(BackendError::NotAvailable { backend, reason }) => {
157 UserFacingError {
158 summary: format!("{} unavailable", backend),
159 message: format!("{} is not available: {}", backend, reason),
160 suggestion: if backend == "ollama" {
161 "Start Ollama with 'ollama serve' or pull the model with 'ollama pull <model>'".to_string()
162 } else {
163 format!("Ensure {} service is running and healthy", backend)
164 },
165 category: ErrorCategory::Connection,
166 recoverable: true,
167 }
168 },
169 ModelError::Backend(BackendError::HttpError { status, message }) => {
170 let (summary, suggestion) = match status {
171 401 | 403 => (
172 "Authentication failed",
173 "Check your API key in ~/.config/mermaid/config.toml",
174 ),
175 404 => (
176 "Model not found",
177 "Use :model <name> to switch models (auto-pulls if needed), or pull manually with 'ollama pull <name>'",
178 ),
179 429 => (
180 "Rate limited",
181 "Wait a moment before retrying, or switch to a local model",
182 ),
183 500..=599 => (
184 "Server error",
185 "The backend service is experiencing issues - try again later",
186 ),
187 _ => (
188 "Request failed",
189 "Check your network connection and backend configuration",
190 ),
191 };
192 UserFacingError {
193 summary: summary.to_string(),
194 message: format!("HTTP {}: {}", status, message),
195 suggestion: suggestion.to_string(),
196 category: if *status == 401 || *status == 403 {
197 ErrorCategory::Auth
198 } else if *status == 429 {
199 ErrorCategory::Temporary
200 } else {
201 ErrorCategory::Internal
202 },
203 recoverable: *status == 429 || *status >= 500,
204 }
205 },
206 ModelError::Backend(BackendError::UnexpectedResponse { backend, message }) => {
207 UserFacingError {
208 summary: "Unexpected response".to_string(),
209 message: format!("Received unexpected response from {}: {}", backend, message),
210 suggestion: "This might be a version mismatch - try updating the backend"
211 .to_string(),
212 category: ErrorCategory::Internal,
213 recoverable: false,
214 }
215 },
216 ModelError::Backend(BackendError::ProviderError {
217 provider,
218 code,
219 message,
220 }) => {
221 let code_str = code.as_deref().unwrap_or("unknown");
222 UserFacingError {
223 summary: format!("{} error", provider),
224 message: format!("{} returned error {}: {}", provider, code_str, message),
225 suggestion: format!(
226 "Check {} documentation for error code {}",
227 provider, code_str
228 ),
229 category: ErrorCategory::Internal,
230 recoverable: false,
231 }
232 },
233 ModelError::Config(ConfigError::MissingRequired(field)) => UserFacingError {
234 summary: "Missing configuration".to_string(),
235 message: format!("Required configuration '{}' is missing", field),
236 suggestion: format!("Add '{}' to ~/.config/mermaid/config.toml", field),
237 category: ErrorCategory::Config,
238 recoverable: false,
239 },
240 ModelError::Config(ConfigError::InvalidValue {
241 field,
242 value,
243 reason,
244 }) => UserFacingError {
245 summary: "Invalid configuration".to_string(),
246 message: format!("Invalid value '{}' for '{}': {}", value, field, reason),
247 suggestion: format!("Fix '{}' in ~/.config/mermaid/config.toml", field),
248 category: ErrorCategory::Config,
249 recoverable: false,
250 },
251 ModelError::Config(ConfigError::FileError { path, reason }) => UserFacingError {
252 summary: "Config file error".to_string(),
253 message: format!("Cannot read config file '{}': {}", path, reason),
254 suggestion: "Check file permissions and syntax".to_string(),
255 category: ErrorCategory::Config,
256 recoverable: false,
257 },
258 ModelError::ModelNotFound { model, searched } => UserFacingError {
259 summary: "Model not found".to_string(),
260 message: format!("Model '{}' not found in: {}", model, searched.join(", ")),
261 suggestion: format!(
262 "Pull the model with 'ollama pull {}' or check if the model name is correct",
263 model
264 ),
265 category: ErrorCategory::NotFound,
266 recoverable: false,
267 },
268 ModelError::Timeout {
269 operation,
270 duration_secs,
271 } => UserFacingError {
272 summary: "Request timed out".to_string(),
273 message: if *duration_secs == 0 {
274 format!("'{}' timed out", operation)
275 } else {
276 format!("'{}' timed out after {} seconds", operation, duration_secs)
277 },
278 suggestion: "The model might be overloaded - try a smaller model or wait and retry"
279 .to_string(),
280 category: ErrorCategory::Temporary,
281 recoverable: true,
282 },
283 ModelError::RateLimit { retry_after } => {
284 let wait_msg = retry_after
285 .map(|s| format!("Wait {} seconds", s))
286 .unwrap_or_else(|| "Wait a moment".to_string());
287 UserFacingError {
288 summary: "Rate limited".to_string(),
289 message: "Too many requests - rate limit exceeded".to_string(),
290 suggestion: format!(
291 "{}. Consider using a local Ollama model to avoid rate limits",
292 wait_msg
293 ),
294 category: ErrorCategory::Temporary,
295 recoverable: true,
296 }
297 },
298 ModelError::InvalidRequest(msg) => UserFacingError {
299 summary: "Invalid request".to_string(),
300 message: format!("The request was invalid: {}", msg),
301 suggestion: "Check your message format or try rephrasing".to_string(),
302 category: ErrorCategory::Internal,
303 recoverable: false,
304 },
305 ModelError::ParseError { message, .. } => UserFacingError {
306 summary: "Parse error".to_string(),
307 message: format!("Failed to parse response: {}", message),
308 suggestion:
309 "The model returned an unexpected format - try sending the message again"
310 .to_string(),
311 category: ErrorCategory::Internal,
312 recoverable: true,
313 },
314 ModelError::StreamError(msg) => UserFacingError {
315 summary: "Stream interrupted".to_string(),
316 message: format!("Connection lost during streaming: {}", msg),
317 suggestion: "Check your network connection and try again".to_string(),
318 category: ErrorCategory::Connection,
319 recoverable: true,
320 },
321 ModelError::Authentication(msg) => UserFacingError {
322 summary: "Authentication failed".to_string(),
323 message: format!("Authentication error: {}", msg),
324 suggestion:
325 "Check your API key in ~/.config/mermaid/config.toml or environment variables"
326 .to_string(),
327 category: ErrorCategory::Auth,
328 recoverable: false,
329 },
330 ModelError::Unsupported { feature } => UserFacingError {
331 summary: "Unsupported feature".to_string(),
332 message: format!("The current model adapter does not support '{}'.", feature),
333 suggestion: format!(
334 "Switch to a provider/model that supports '{}', or omit this operation.",
335 feature
336 ),
337 category: ErrorCategory::Internal,
338 recoverable: false,
339 },
340 }
341 }
342}
343
344#[derive(Debug)]
346pub enum BackendError {
347 ConnectionFailed {
349 backend: String,
350 url: String,
351 reason: String,
352 },
353
354 NotAvailable { backend: String, reason: String },
356
357 HttpError { status: u16, message: String },
359
360 UnexpectedResponse { backend: String, message: String },
362
363 ProviderError {
365 provider: String,
366 code: Option<String>,
367 message: String,
368 },
369}
370
371impl fmt::Display for BackendError {
372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373 match self {
374 BackendError::ConnectionFailed {
375 backend,
376 url,
377 reason,
378 } => {
379 write!(f, "Failed to connect to {} at {}: {}", backend, url, reason)
380 },
381 BackendError::NotAvailable { backend, reason } => {
382 write!(f, "Backend '{}' not available: {}", backend, reason)
383 },
384 BackendError::HttpError { status, message } => {
385 write!(f, "HTTP error {}: {}", status, message)
386 },
387 BackendError::UnexpectedResponse { backend, message } => {
388 write!(f, "Unexpected response from {}: {}", backend, message)
389 },
390 BackendError::ProviderError {
391 provider,
392 code,
393 message,
394 } => {
395 if let Some(c) = code {
396 write!(f, "{} error {}: {}", provider, c, message)
397 } else {
398 write!(f, "{} error: {}", provider, message)
399 }
400 },
401 }
402 }
403}
404
405impl std::error::Error for BackendError {}
406
407#[derive(Debug)]
409pub enum ConfigError {
410 MissingRequired(String),
412
413 InvalidValue {
415 field: String,
416 value: String,
417 reason: String,
418 },
419
420 FileError { path: String, reason: String },
422}
423
424impl fmt::Display for ConfigError {
425 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426 match self {
427 ConfigError::MissingRequired(field) => {
428 write!(f, "Missing required configuration: {}", field)
429 },
430 ConfigError::InvalidValue {
431 field,
432 value,
433 reason,
434 } => {
435 write!(f, "Invalid value for '{}': '{}' ({})", field, value, reason)
436 },
437 ConfigError::FileError { path, reason } => {
438 write!(f, "Error reading config file '{}': {}", path, reason)
439 },
440 }
441 }
442}
443
444impl std::error::Error for ConfigError {}
445
446pub type Result<T> = std::result::Result<T, ModelError>;
448
449impl From<anyhow::Error> for ModelError {
451 fn from(err: anyhow::Error) -> Self {
452 ModelError::InvalidRequest(err.to_string())
453 }
454}
455
456impl From<reqwest::Error> for ModelError {
458 fn from(err: reqwest::Error) -> Self {
459 if err.is_timeout() {
460 ModelError::Timeout {
467 operation: "HTTP request".to_string(),
468 duration_secs: 0,
469 }
470 } else if err.is_connect() {
471 ModelError::Backend(BackendError::ConnectionFailed {
472 backend: "unknown".to_string(),
473 url: err
474 .url()
475 .map(|u| u.to_string())
476 .unwrap_or_else(|| "unknown".to_string()),
477 reason: err.to_string(),
478 })
479 } else if err.is_status() {
480 let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
481 ModelError::Backend(BackendError::HttpError {
482 status,
483 message: err.to_string(),
484 })
485 } else {
486 ModelError::Backend(BackendError::UnexpectedResponse {
487 backend: "unknown".to_string(),
488 message: err.to_string(),
489 })
490 }
491 }
492}
493
494impl From<serde_json::Error> for ModelError {
496 fn from(err: serde_json::Error) -> Self {
497 ModelError::ParseError {
498 message: err.to_string(),
499 raw: None,
500 }
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn timeout_display_omits_zero_duration() {
510 let err = ModelError::Timeout {
511 operation: "HTTP request".to_string(),
512 duration_secs: 0,
513 };
514 let rendered = err.to_string();
515 assert_eq!(rendered, "Operation 'HTTP request' timed out");
516 assert!(!rendered.contains("0 seconds"));
517 }
518
519 #[test]
520 fn timeout_display_shows_nonzero_duration() {
521 let err = ModelError::Timeout {
522 operation: "HTTP request".to_string(),
523 duration_secs: 45,
524 };
525 let rendered = err.to_string();
526 assert_eq!(
527 rendered,
528 "Operation 'HTTP request' timed out after 45 seconds"
529 );
530 }
531
532 #[test]
533 fn timeout_user_facing_omits_zero_duration() {
534 let err = ModelError::Timeout {
535 operation: "HTTP request".to_string(),
536 duration_secs: 0,
537 };
538 let ufe = err.to_user_facing();
539 assert_eq!(ufe.message, "'HTTP request' timed out");
540 assert!(!ufe.message.contains("0 seconds"));
541 }
542}