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 Cancelled,
91}
92
93impl fmt::Display for ModelError {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 match self {
96 ModelError::Backend(e) => write!(f, "Backend error: {}", e),
97 ModelError::Config(e) => write!(f, "Configuration error: {}", e),
98 ModelError::ModelNotFound { model, searched } => {
99 write!(
100 f,
101 "Model '{}' not found. Searched: {}",
102 model,
103 searched.join(", ")
104 )
105 },
106 ModelError::Timeout {
107 operation,
108 duration_secs,
109 } => {
110 if *duration_secs == 0 {
111 write!(f, "Operation '{}' timed out", operation)
112 } else {
113 write!(
114 f,
115 "Operation '{}' timed out after {} seconds",
116 operation, duration_secs
117 )
118 }
119 },
120 ModelError::RateLimit { retry_after } => {
121 if let Some(secs) = retry_after {
122 write!(f, "Rate limit exceeded. Retry after {} seconds", secs)
123 } else {
124 write!(f, "Rate limit exceeded")
125 }
126 },
127 ModelError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
128 ModelError::ParseError { message, raw } => {
129 if let Some(r) = raw {
130 write!(f, "Parse error: {} (raw: {})", message, r)
131 } else {
132 write!(f, "Parse error: {}", message)
133 }
134 },
135 ModelError::StreamError(msg) => write!(f, "Stream error: {}", msg),
136 ModelError::Authentication(msg) => write!(f, "Authentication error: {}", msg),
137 ModelError::Unsupported { feature } => {
138 write!(f, "Feature not supported by this adapter: {}", feature)
139 },
140 ModelError::Cancelled => write!(f, "Cancelled by user"),
141 }
142 }
143}
144
145impl std::error::Error for ModelError {}
146
147impl ModelError {
148 pub fn to_user_facing(&self) -> UserFacingError {
150 match self {
151 ModelError::Backend(BackendError::ConnectionFailed { backend, url, .. }) => {
152 UserFacingError {
153 summary: format!("{} connection failed", backend),
154 message: format!("Could not connect to {} at {}", backend, url),
155 suggestion: if backend == "ollama" {
156 "Run 'ollama serve' to start Ollama, or check if it's running on the correct port".to_string()
157 } else {
158 format!("Check if {} is running and accessible", backend)
159 },
160 category: ErrorCategory::Connection,
161 recoverable: true,
162 }
163 },
164 ModelError::Backend(BackendError::NotAvailable { backend, reason }) => {
165 UserFacingError {
166 summary: format!("{} unavailable", backend),
167 message: format!("{} is not available: {}", backend, reason),
168 suggestion: if backend == "ollama" {
169 "Start Ollama with 'ollama serve' or pull the model with 'ollama pull <model>'".to_string()
170 } else {
171 format!("Ensure {} service is running and healthy", backend)
172 },
173 category: ErrorCategory::Connection,
174 recoverable: true,
175 }
176 },
177 ModelError::Backend(BackendError::HttpError { status, message }) => {
178 let (summary, suggestion) = match status {
179 401 | 403 => (
180 "Authentication failed",
181 "Check your API key in ~/.config/mermaid/config.toml",
182 ),
183 404 => (
184 "Model not found",
185 "Use /model <name> to switch models (auto-pulls if needed), or pull manually with 'ollama pull <name>'",
186 ),
187 429 => (
188 "Rate limited",
189 "Wait a moment before retrying, or switch to a local model",
190 ),
191 500..=599 => (
192 "Server error",
193 "The backend service is experiencing issues - try again later",
194 ),
195 _ => (
196 "Request failed",
197 "Check your network connection and backend configuration",
198 ),
199 };
200 let rendered = match try_extract_error_message(message) {
205 Some(clean) => format!("HTTP {}: {}", status, clean),
206 None => format!("HTTP {}: {}", status, message),
207 };
208 UserFacingError {
209 summary: summary.to_string(),
210 message: rendered,
211 suggestion: suggestion.to_string(),
212 category: if *status == 401 || *status == 403 {
218 ErrorCategory::Auth
219 } else if *status == 429 || (500..=599).contains(status) {
220 ErrorCategory::Temporary
221 } else {
222 ErrorCategory::Internal
223 },
224 recoverable: *status == 429 || *status >= 500,
225 }
226 },
227 ModelError::Backend(BackendError::UnexpectedResponse { backend, message }) => {
228 UserFacingError {
229 summary: "Unexpected response".to_string(),
230 message: format!("Received unexpected response from {}: {}", backend, message),
231 suggestion: "This might be a version mismatch - try updating the backend"
232 .to_string(),
233 category: ErrorCategory::Internal,
234 recoverable: false,
235 }
236 },
237 ModelError::Backend(BackendError::ProviderError {
238 provider,
239 code,
240 message,
241 }) => {
242 let code_str = code.as_deref().unwrap_or("unknown");
243 UserFacingError {
244 summary: format!("{} error", provider),
245 message: format!("{} returned error {}: {}", provider, code_str, message),
246 suggestion: format!(
247 "Check {} documentation for error code {}",
248 provider, code_str
249 ),
250 category: ErrorCategory::Internal,
251 recoverable: false,
252 }
253 },
254 ModelError::Config(ConfigError::MissingRequired(field)) => UserFacingError {
255 summary: "Missing configuration".to_string(),
256 message: format!("Required configuration '{}' is missing", field),
257 suggestion: format!("Add '{}' to ~/.config/mermaid/config.toml", field),
258 category: ErrorCategory::Config,
259 recoverable: false,
260 },
261 ModelError::Config(ConfigError::InvalidValue {
262 field,
263 value,
264 reason,
265 }) => UserFacingError {
266 summary: "Invalid configuration".to_string(),
267 message: format!("Invalid value '{}' for '{}': {}", value, field, reason),
268 suggestion: format!("Fix '{}' in ~/.config/mermaid/config.toml", field),
269 category: ErrorCategory::Config,
270 recoverable: false,
271 },
272 ModelError::Config(ConfigError::FileError { path, reason }) => UserFacingError {
273 summary: "Config file error".to_string(),
274 message: format!("Cannot read config file '{}': {}", path, reason),
275 suggestion: "Check file permissions and syntax".to_string(),
276 category: ErrorCategory::Config,
277 recoverable: false,
278 },
279 ModelError::ModelNotFound { model, searched } => UserFacingError {
280 summary: "Model not found".to_string(),
281 message: format!("Model '{}' not found in: {}", model, searched.join(", ")),
282 suggestion: format!(
283 "Pull the model with 'ollama pull {}' or check if the model name is correct",
284 model
285 ),
286 category: ErrorCategory::NotFound,
287 recoverable: false,
288 },
289 ModelError::Timeout {
290 operation,
291 duration_secs,
292 } => UserFacingError {
293 summary: "Request timed out".to_string(),
294 message: if *duration_secs == 0 {
295 format!("'{}' timed out", operation)
296 } else {
297 format!("'{}' timed out after {} seconds", operation, duration_secs)
298 },
299 suggestion: "The model might be overloaded - try a smaller model or wait and retry"
300 .to_string(),
301 category: ErrorCategory::Temporary,
302 recoverable: true,
303 },
304 ModelError::RateLimit { retry_after } => {
305 let wait_msg = retry_after
306 .map(|s| format!("Wait {} seconds", s))
307 .unwrap_or_else(|| "Wait a moment".to_string());
308 UserFacingError {
309 summary: "Rate limited".to_string(),
310 message: "Too many requests - rate limit exceeded".to_string(),
311 suggestion: format!(
312 "{}. Consider using a local Ollama model to avoid rate limits",
313 wait_msg
314 ),
315 category: ErrorCategory::Temporary,
316 recoverable: true,
317 }
318 },
319 ModelError::InvalidRequest(msg) => UserFacingError {
320 summary: "Invalid request".to_string(),
321 message: format!("The request was invalid: {}", msg),
322 suggestion: "Check your message format or try rephrasing".to_string(),
323 category: ErrorCategory::Internal,
324 recoverable: false,
325 },
326 ModelError::ParseError { message, .. } => UserFacingError {
327 summary: "Parse error".to_string(),
328 message: format!("Failed to parse response: {}", message),
329 suggestion:
330 "The model returned an unexpected format - try sending the message again"
331 .to_string(),
332 category: ErrorCategory::Internal,
333 recoverable: true,
334 },
335 ModelError::StreamError(msg) => UserFacingError {
336 summary: "Stream interrupted".to_string(),
337 message: format!("Connection lost during streaming: {}", msg),
338 suggestion: "Check your network connection and try again".to_string(),
339 category: ErrorCategory::Connection,
340 recoverable: true,
341 },
342 ModelError::Authentication(msg) => UserFacingError {
343 summary: "Authentication failed".to_string(),
344 message: format!("Authentication error: {}", msg),
345 suggestion:
346 "Check your API key in ~/.config/mermaid/config.toml or environment variables"
347 .to_string(),
348 category: ErrorCategory::Auth,
349 recoverable: false,
350 },
351 ModelError::Unsupported { feature } => UserFacingError {
352 summary: "Unsupported feature".to_string(),
353 message: format!("The current model adapter does not support '{}'.", feature),
354 suggestion: format!(
355 "Switch to a provider/model that supports '{}', or omit this operation.",
356 feature
357 ),
358 category: ErrorCategory::Internal,
359 recoverable: false,
360 },
361 ModelError::Cancelled => UserFacingError {
362 summary: "Cancelled".to_string(),
363 message: "The request was cancelled.".to_string(),
364 suggestion: String::new(),
365 category: ErrorCategory::Temporary,
366 recoverable: true,
367 },
368 }
369 }
370}
371
372#[derive(Debug)]
374pub enum BackendError {
375 ConnectionFailed {
377 backend: String,
378 url: String,
379 reason: String,
380 },
381
382 NotAvailable { backend: String, reason: String },
384
385 HttpError { status: u16, message: String },
387
388 UnexpectedResponse { backend: String, message: String },
390
391 ProviderError {
393 provider: String,
394 code: Option<String>,
395 message: String,
396 },
397}
398
399impl fmt::Display for BackendError {
400 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401 match self {
402 BackendError::ConnectionFailed {
403 backend,
404 url,
405 reason,
406 } => {
407 write!(f, "Failed to connect to {} at {}: {}", backend, url, reason)
408 },
409 BackendError::NotAvailable { backend, reason } => {
410 write!(f, "Backend '{}' not available: {}", backend, reason)
411 },
412 BackendError::HttpError { status, message } => {
413 write!(f, "HTTP error {}: {}", status, message)
414 },
415 BackendError::UnexpectedResponse { backend, message } => {
416 write!(f, "Unexpected response from {}: {}", backend, message)
417 },
418 BackendError::ProviderError {
419 provider,
420 code,
421 message,
422 } => {
423 if let Some(c) = code {
424 write!(f, "{} error {}: {}", provider, c, message)
425 } else {
426 write!(f, "{} error: {}", provider, message)
427 }
428 },
429 }
430 }
431}
432
433impl std::error::Error for BackendError {}
434
435#[derive(Debug)]
437pub enum ConfigError {
438 MissingRequired(String),
440
441 InvalidValue {
443 field: String,
444 value: String,
445 reason: String,
446 },
447
448 FileError { path: String, reason: String },
450}
451
452impl fmt::Display for ConfigError {
453 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454 match self {
455 ConfigError::MissingRequired(field) => {
456 write!(f, "Missing required configuration: {}", field)
457 },
458 ConfigError::InvalidValue {
459 field,
460 value,
461 reason,
462 } => {
463 write!(f, "Invalid value for '{}': '{}' ({})", field, value, reason)
464 },
465 ConfigError::FileError { path, reason } => {
466 write!(f, "Error reading config file '{}': {}", path, reason)
467 },
468 }
469 }
470}
471
472impl std::error::Error for ConfigError {}
473
474pub type Result<T> = std::result::Result<T, ModelError>;
476
477impl From<anyhow::Error> for ModelError {
479 fn from(err: anyhow::Error) -> Self {
480 ModelError::InvalidRequest(err.to_string())
481 }
482}
483
484impl From<reqwest::Error> for ModelError {
486 fn from(err: reqwest::Error) -> Self {
487 if err.is_timeout() {
488 ModelError::Timeout {
495 operation: "HTTP request".to_string(),
496 duration_secs: 0,
497 }
498 } else if err.is_connect() {
499 ModelError::Backend(BackendError::ConnectionFailed {
500 backend: "unknown".to_string(),
501 url: err
502 .url()
503 .map(|u| u.to_string())
504 .unwrap_or_else(|| "unknown".to_string()),
505 reason: err.to_string(),
506 })
507 } else if err.is_status() {
508 let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
509 ModelError::Backend(BackendError::HttpError {
510 status,
511 message: err.to_string(),
512 })
513 } else {
514 ModelError::Backend(BackendError::UnexpectedResponse {
515 backend: "unknown".to_string(),
516 message: err.to_string(),
517 })
518 }
519 }
520}
521
522impl From<serde_json::Error> for ModelError {
524 fn from(err: serde_json::Error) -> Self {
525 ModelError::ParseError {
526 message: err.to_string(),
527 raw: None,
528 }
529 }
530}
531
532fn try_extract_error_message(body: &str) -> Option<String> {
544 let trimmed = body.trim();
545 if !trimmed.starts_with('{') {
546 return None;
547 }
548 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
549 let error = value.get("error")?;
550
551 if let Some(s) = error.as_str() {
553 return Some(s.trim().to_string());
554 }
555
556 if let Some(obj) = error.as_object() {
560 let message = obj.get("message").and_then(|v| v.as_str())?;
561 let kind = obj
562 .get("type")
563 .and_then(|v| v.as_str())
564 .or_else(|| obj.get("code").and_then(|v| v.as_str()));
565 let out = match kind {
566 Some(k) if !k.is_empty() => format!("{}: {}", k, message),
567 _ => message.to_string(),
568 };
569 return Some(out.trim().to_string());
570 }
571
572 None
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn timeout_display_omits_zero_duration() {
581 let err = ModelError::Timeout {
582 operation: "HTTP request".to_string(),
583 duration_secs: 0,
584 };
585 let rendered = err.to_string();
586 assert_eq!(rendered, "Operation 'HTTP request' timed out");
587 assert!(!rendered.contains("0 seconds"));
588 }
589
590 #[test]
591 fn timeout_display_shows_nonzero_duration() {
592 let err = ModelError::Timeout {
593 operation: "HTTP request".to_string(),
594 duration_secs: 45,
595 };
596 let rendered = err.to_string();
597 assert_eq!(
598 rendered,
599 "Operation 'HTTP request' timed out after 45 seconds"
600 );
601 }
602
603 #[test]
604 fn timeout_user_facing_omits_zero_duration() {
605 let err = ModelError::Timeout {
606 operation: "HTTP request".to_string(),
607 duration_secs: 0,
608 };
609 let ufe = err.to_user_facing();
610 assert_eq!(ufe.message, "'HTTP request' timed out");
611 assert!(!ufe.message.contains("0 seconds"));
612 }
613
614 #[test]
615 fn extract_error_handles_ollama_string_shape() {
616 let body = r#"{"error":"Internal Server Error (ref: 6e8ae4c7)"}"#;
617 assert_eq!(
618 try_extract_error_message(body).as_deref(),
619 Some("Internal Server Error (ref: 6e8ae4c7)")
620 );
621 }
622
623 #[test]
624 fn extract_error_handles_openai_object_shape_with_type() {
625 let body = r#"{"error":{"message":"Rate limit","type":"rate_limit_error","code":null}}"#;
626 assert_eq!(
627 try_extract_error_message(body).as_deref(),
628 Some("rate_limit_error: Rate limit")
629 );
630 }
631
632 #[test]
635 fn extract_error_handles_openrouter_numeric_code() {
636 let body = r#"{"error":{"message":"upstream timeout","code":504,"metadata":{}}}"#;
637 assert_eq!(
638 try_extract_error_message(body).as_deref(),
639 Some("upstream timeout")
640 );
641 }
642
643 #[test]
644 fn extract_error_returns_none_for_non_json() {
645 assert_eq!(try_extract_error_message("<html>bad gateway</html>"), None);
646 assert_eq!(try_extract_error_message(""), None);
647 assert_eq!(try_extract_error_message("plain text error"), None);
648 }
649
650 #[test]
651 fn extract_error_returns_none_for_missing_error_field() {
652 let body = r#"{"status":"ok","message":"nothing here"}"#;
653 assert_eq!(try_extract_error_message(body), None);
654 }
655
656 #[test]
661 fn http_500_renders_clean_message_and_temporary_category() {
662 let err = ModelError::Backend(BackendError::HttpError {
663 status: 500,
664 message: r#"{"error":"Internal Server Error (ref: abc-123)"}"#.to_string(),
665 });
666 let ufe = err.to_user_facing();
667 assert_eq!(ufe.summary, "Server error");
668 assert_eq!(
669 ufe.message,
670 "HTTP 500: Internal Server Error (ref: abc-123)"
671 );
672 assert!(ufe.recoverable);
673 assert_eq!(ufe.category, ErrorCategory::Temporary);
674 }
675
676 #[test]
679 fn http_500_falls_back_to_raw_body_for_html() {
680 let err = ModelError::Backend(BackendError::HttpError {
681 status: 502,
682 message: "<html>Bad Gateway</html>".to_string(),
683 });
684 let ufe = err.to_user_facing();
685 assert_eq!(ufe.message, "HTTP 502: <html>Bad Gateway</html>");
686 }
687}