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 {
68 retry_after: Option<u64>,
69 message: Option<String>,
70 },
71
72 InvalidRequest(String),
74
75 ParseError {
77 message: String,
78 raw: Option<String>,
79 },
80
81 StreamError(String),
83
84 Authentication(String),
86
87 Unsupported { feature: String },
91
92 Cancelled,
98}
99
100impl fmt::Display for ModelError {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 match self {
103 Self::Backend(e) => write!(f, "Backend error: {e}"),
104 Self::Config(e) => write!(f, "Configuration error: {e}"),
105 Self::ModelNotFound { model, searched } => {
106 write!(
107 f,
108 "Model '{}' not found. Searched: {}",
109 model,
110 searched.join(", ")
111 )
112 },
113 Self::Timeout {
114 operation,
115 duration_secs,
116 } => {
117 if *duration_secs == 0 {
118 write!(f, "Operation '{operation}' timed out")
119 } else {
120 write!(
121 f,
122 "Operation '{operation}' timed out after {duration_secs} seconds"
123 )
124 }
125 },
126 Self::RateLimit {
127 retry_after,
128 message,
129 } => {
130 write!(f, "Rate limit exceeded")?;
131 if let Some(secs) = retry_after {
132 write!(f, " (retry after {secs} seconds)")?;
133 }
134 if let Some(reason) = message {
135 write!(f, ": {reason}")?;
136 }
137 Ok(())
138 },
139 Self::InvalidRequest(msg) => write!(f, "Invalid request: {msg}"),
140 Self::ParseError { message, raw } => {
141 if let Some(r) = raw {
142 write!(f, "Parse error: {message} (raw: {r})")
143 } else {
144 write!(f, "Parse error: {message}")
145 }
146 },
147 Self::StreamError(msg) => write!(f, "Stream error: {msg}"),
148 Self::Authentication(msg) => write!(f, "Authentication error: {msg}"),
149 Self::Unsupported { feature } => {
150 write!(f, "Feature not supported by this adapter: {feature}")
151 },
152 Self::Cancelled => write!(f, "Cancelled by user"),
153 }
154 }
155}
156
157impl std::error::Error for ModelError {}
158
159impl ModelError {
160 #[expect(
162 clippy::too_many_lines,
163 reason = "predates the lint; see .github/baselines/expect_budget.txt"
164 )]
165 #[must_use]
166 pub fn to_user_facing(&self) -> UserFacingError {
167 match self {
168 Self::Backend(BackendError::ConnectionFailed { backend, url, .. }) => UserFacingError {
169 summary: format!("{backend} connection failed"),
170 message: format!("Could not connect to {backend} at {url}"),
171 suggestion: if backend == "ollama" {
172 "Run 'ollama serve' to start Ollama, or check if it's running on the correct port".to_string()
173 } else {
174 format!("Check if {backend} is running and accessible")
175 },
176 category: ErrorCategory::Connection,
177 recoverable: true,
178 },
179 Self::Backend(BackendError::NotAvailable { backend, reason }) => UserFacingError {
180 summary: format!("{backend} unavailable"),
181 message: format!("{backend} is not available: {reason}"),
182 suggestion: if backend == "ollama" {
183 "Start Ollama with 'ollama serve' or pull the model with 'ollama pull <model>'"
184 .to_string()
185 } else {
186 format!("Ensure {backend} service is running and healthy")
187 },
188 category: ErrorCategory::Connection,
189 recoverable: true,
190 },
191 Self::Backend(BackendError::HttpError {
192 status,
193 message,
194 debug,
195 }) => {
196 let (summary, suggestion) = match status {
197 401 | 403 => (
198 "Authentication failed",
199 "Check your API key in ~/.config/mermaid/config.toml",
200 ),
201 404 => (
202 "Model not found",
203 "Use /model <name> to switch models (auto-pulls if needed), or pull manually with 'ollama pull <name>'",
204 ),
205 429 => (
206 "Rate limited",
207 "Wait a moment before retrying, or switch to a local model",
208 ),
209 500..=599 => (
210 "Server error",
211 "The backend service is experiencing issues - try again later",
212 ),
213 _ => (
214 "Request failed",
215 "Check your network connection and backend configuration",
216 ),
217 };
218 let rendered = match try_extract_error_message(message) {
223 Some(clean) => format!("HTTP {status}: {clean}"),
224 None => format!("HTTP {status}: {message}"),
225 };
226 UserFacingError {
227 summary: summary.to_string(),
228 message: debug.suffix(rendered),
229 suggestion: suggestion.to_string(),
230 category: if *status == 401 || *status == 403 {
236 ErrorCategory::Auth
237 } else if *status == 429 || (500..=599).contains(status) {
238 ErrorCategory::Temporary
239 } else {
240 ErrorCategory::Internal
241 },
242 recoverable: *status == 429 || *status >= 500,
243 }
244 },
245 Self::Backend(BackendError::UnexpectedResponse { backend, message }) => {
246 UserFacingError {
247 summary: "Unexpected response".to_string(),
248 message: format!("Received unexpected response from {backend}: {message}"),
249 suggestion: "This might be a version mismatch - try updating the backend"
250 .to_string(),
251 category: ErrorCategory::Internal,
252 recoverable: false,
253 }
254 },
255 Self::Backend(BackendError::ProviderError {
256 provider,
257 code,
258 message,
259 debug,
260 }) => {
261 let code_str = code.as_deref().unwrap_or("unknown");
262 UserFacingError {
263 summary: format!("{provider} error"),
264 message: debug
265 .suffix(format!("{provider} returned error {code_str}: {message}")),
266 suggestion: format!("Check {provider} documentation for error code {code_str}"),
267 category: ErrorCategory::Internal,
268 recoverable: false,
269 }
270 },
271 Self::Config(ConfigError::MissingRequired(field)) => UserFacingError {
272 summary: "Missing configuration".to_string(),
273 message: format!("Required configuration '{field}' is missing"),
274 suggestion: format!("Add '{field}' to ~/.config/mermaid/config.toml"),
275 category: ErrorCategory::Config,
276 recoverable: false,
277 },
278 Self::Config(ConfigError::InvalidValue {
279 field,
280 value,
281 reason,
282 }) => UserFacingError {
283 summary: "Invalid configuration".to_string(),
284 message: format!("Invalid value '{value}' for '{field}': {reason}"),
285 suggestion: format!("Fix '{field}' in ~/.config/mermaid/config.toml"),
286 category: ErrorCategory::Config,
287 recoverable: false,
288 },
289 Self::Config(ConfigError::FileError { path, reason }) => UserFacingError {
290 summary: "Config file error".to_string(),
291 message: format!("Cannot read config file '{path}': {reason}"),
292 suggestion: "Check file permissions and syntax".to_string(),
293 category: ErrorCategory::Config,
294 recoverable: false,
295 },
296 Self::ModelNotFound { model, searched } => UserFacingError {
297 summary: "Model not found".to_string(),
298 message: format!("Model '{}' not found in: {}", model, searched.join(", ")),
299 suggestion: format!(
300 "Pull the model with 'ollama pull {model}' or check if the model name is correct"
301 ),
302 category: ErrorCategory::NotFound,
303 recoverable: false,
304 },
305 Self::Timeout {
306 operation,
307 duration_secs,
308 } => UserFacingError {
309 summary: "Request timed out".to_string(),
310 message: if *duration_secs == 0 {
311 format!("'{operation}' timed out")
312 } else {
313 format!("'{operation}' timed out after {duration_secs} seconds")
314 },
315 suggestion: "The model might be overloaded - try a smaller model or wait and retry"
316 .to_string(),
317 category: ErrorCategory::Temporary,
318 recoverable: true,
319 },
320 Self::RateLimit {
321 retry_after,
322 message,
323 } => {
324 let wait_msg = retry_after
325 .map(|s| format!("Wait {s} seconds and retry"))
326 .unwrap_or_else(|| {
327 "This can be a burst limit (retry shortly) or an exhausted quota"
328 .to_string()
329 });
330 UserFacingError {
331 summary: "Rate limited".to_string(),
332 message: message.clone().unwrap_or_else(|| {
336 "The provider rejected the request with 429 (too many requests)".to_string()
337 }),
338 suggestion: format!("{wait_msg}. Local Ollama models have no rate limits"),
339 category: ErrorCategory::Temporary,
340 recoverable: true,
341 }
342 },
343 Self::InvalidRequest(msg) => UserFacingError {
344 summary: "Invalid request".to_string(),
345 message: format!("The request was invalid: {msg}"),
346 suggestion: "Check your message format or try rephrasing".to_string(),
347 category: ErrorCategory::Internal,
348 recoverable: false,
349 },
350 Self::ParseError { message, .. } => UserFacingError {
351 summary: "Parse error".to_string(),
352 message: format!("Failed to parse response: {message}"),
353 suggestion:
354 "The model returned an unexpected format - try sending the message again"
355 .to_string(),
356 category: ErrorCategory::Internal,
357 recoverable: true,
358 },
359 Self::StreamError(msg) => UserFacingError {
360 summary: "Stream interrupted".to_string(),
361 message: format!("Connection lost during streaming: {msg}"),
362 suggestion: "Check your network connection and try again".to_string(),
363 category: ErrorCategory::Connection,
364 recoverable: true,
365 },
366 Self::Authentication(msg) => UserFacingError {
367 summary: "Authentication failed".to_string(),
368 message: format!("Authentication error: {msg}"),
369 suggestion:
370 "Check your API key in ~/.config/mermaid/config.toml or environment variables"
371 .to_string(),
372 category: ErrorCategory::Auth,
373 recoverable: false,
374 },
375 Self::Unsupported { feature } => UserFacingError {
376 summary: "Unsupported feature".to_string(),
377 message: format!("The current model adapter does not support '{feature}'."),
378 suggestion: format!(
379 "Switch to a provider/model that supports '{feature}', or omit this operation."
380 ),
381 category: ErrorCategory::Internal,
382 recoverable: false,
383 },
384 Self::Cancelled => UserFacingError {
385 summary: "Cancelled".to_string(),
386 message: "The request was cancelled.".to_string(),
387 suggestion: String::new(),
388 category: ErrorCategory::Temporary,
389 recoverable: true,
390 },
391 }
392 }
393}
394
395#[derive(Debug, Default, Clone, PartialEq, Eq)]
400pub struct ResponseDebugContext {
401 pub request_id: Option<String>,
404 pub cf_ray: Option<String>,
407}
408
409impl ResponseDebugContext {
410 pub fn from_headers(headers: &reqwest::header::HeaderMap) -> Self {
413 let get = |name: &str| {
414 headers
415 .get(name)
416 .and_then(|v| v.to_str().ok())
417 .map(|s| s.trim().to_string())
418 .filter(|s| !s.is_empty())
419 };
420 let captured = Self {
421 request_id: ["x-request-id", "request-id", "anthropic-request-id"]
422 .iter()
423 .find_map(|n| get(n)),
424 cf_ray: get("cf-ray"),
425 };
426 if !captured.is_empty() {
427 tracing::trace!(
429 request_id = ?captured.request_id,
430 cf_ray = ?captured.cf_ray,
431 "captured provider response ids"
432 );
433 }
434 captured
435 }
436
437 #[must_use]
438 pub fn is_empty(&self) -> bool {
439 self.request_id.is_none() && self.cf_ray.is_none()
440 }
441
442 fn render(&self) -> Option<String> {
445 let parts: Vec<String> = [
446 self.request_id
447 .as_ref()
448 .map(|id| format!("request-id: {id}")),
449 self.cf_ray.as_ref().map(|ray| format!("cf-ray: {ray}")),
450 ]
451 .into_iter()
452 .flatten()
453 .collect();
454 if parts.is_empty() {
455 None
456 } else {
457 Some(format!("({})", parts.join(", ")))
458 }
459 }
460
461 fn suffix(&self, message: String) -> String {
463 match self.render() {
464 Some(line) => format!("{message}\n{line}"),
465 None => message,
466 }
467 }
468}
469
470#[derive(Debug)]
472pub enum BackendError {
473 ConnectionFailed {
475 backend: String,
476 url: String,
477 reason: String,
478 },
479
480 NotAvailable { backend: String, reason: String },
482
483 HttpError {
485 status: u16,
486 message: String,
487 debug: ResponseDebugContext,
490 },
491
492 UnexpectedResponse { backend: String, message: String },
494
495 ProviderError {
497 provider: String,
498 code: Option<String>,
499 message: String,
500 debug: ResponseDebugContext,
503 },
504}
505
506impl fmt::Display for BackendError {
507 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
508 match self {
509 Self::ConnectionFailed {
510 backend,
511 url,
512 reason,
513 } => {
514 write!(f, "Failed to connect to {backend} at {url}: {reason}")
515 },
516 Self::NotAvailable { backend, reason } => {
517 write!(f, "Backend '{backend}' not available: {reason}")
518 },
519 Self::HttpError {
523 status, message, ..
524 } => {
525 write!(f, "HTTP error {status}: {message}")
526 },
527 Self::UnexpectedResponse { backend, message } => {
528 write!(f, "Unexpected response from {backend}: {message}")
529 },
530 Self::ProviderError {
531 provider,
532 code,
533 message,
534 ..
535 } => {
536 if let Some(c) = code {
537 write!(f, "{provider} error {c}: {message}")
538 } else {
539 write!(f, "{provider} error: {message}")
540 }
541 },
542 }
543 }
544}
545
546impl std::error::Error for BackendError {}
547
548#[derive(Debug)]
550pub enum ConfigError {
551 MissingRequired(String),
553
554 InvalidValue {
556 field: String,
557 value: String,
558 reason: String,
559 },
560
561 FileError { path: String, reason: String },
563}
564
565impl fmt::Display for ConfigError {
566 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
567 match self {
568 Self::MissingRequired(field) => {
569 write!(f, "Missing required configuration: {field}")
570 },
571 Self::InvalidValue {
572 field,
573 value,
574 reason,
575 } => {
576 write!(f, "Invalid value for '{field}': '{value}' ({reason})")
577 },
578 Self::FileError { path, reason } => {
579 write!(f, "Error reading config file '{path}': {reason}")
580 },
581 }
582 }
583}
584
585impl std::error::Error for ConfigError {}
586
587pub type Result<T> = std::result::Result<T, ModelError>;
589
590impl From<anyhow::Error> for ModelError {
592 fn from(err: anyhow::Error) -> Self {
593 Self::InvalidRequest(err.to_string())
594 }
595}
596
597impl From<reqwest::Error> for ModelError {
599 fn from(err: reqwest::Error) -> Self {
600 if err.is_timeout() {
601 Self::Timeout {
608 operation: "HTTP request".to_string(),
609 duration_secs: 0,
610 }
611 } else if err.is_connect() {
612 Self::Backend(BackendError::ConnectionFailed {
613 backend: "unknown".to_string(),
614 url: err
615 .url()
616 .map(|u| u.to_string())
617 .unwrap_or_else(|| "unknown".to_string()),
618 reason: err.to_string(),
619 })
620 } else if err.is_status() {
621 let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
622 Self::Backend(BackendError::HttpError {
623 status,
624 message: err.to_string(),
625 debug: ResponseDebugContext::default(),
626 })
627 } else {
628 Self::Backend(BackendError::UnexpectedResponse {
629 backend: "unknown".to_string(),
630 message: err.to_string(),
631 })
632 }
633 }
634}
635
636impl From<serde_json::Error> for ModelError {
638 fn from(err: serde_json::Error) -> Self {
639 Self::ParseError {
640 message: err.to_string(),
641 raw: None,
642 }
643 }
644}
645
646fn try_extract_error_message(body: &str) -> Option<String> {
658 let trimmed = body.trim();
659 if !trimmed.starts_with('{') {
660 return None;
661 }
662 let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
663 let error = value.get("error")?;
664
665 if let Some(s) = error.as_str() {
667 return Some(s.trim().to_string());
668 }
669
670 if let Some(obj) = error.as_object() {
674 let message = obj.get("message").and_then(|v| v.as_str())?;
675 let kind = obj
676 .get("type")
677 .and_then(|v| v.as_str())
678 .or_else(|| obj.get("code").and_then(|v| v.as_str()));
679 let out = match kind {
680 Some(k) if !k.is_empty() => format!("{k}: {message}"),
681 _ => message.to_string(),
682 };
683 return Some(out.trim().to_string());
684 }
685
686 None
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 fn headers(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
694 let mut map = reqwest::header::HeaderMap::new();
695 for (name, value) in pairs {
696 map.insert(
697 reqwest::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
698 value.parse().unwrap(),
699 );
700 }
701 map
702 }
703
704 #[test]
705 fn debug_context_captures_each_request_id_alias() {
706 for alias in ["x-request-id", "request-id", "anthropic-request-id"] {
707 let debug = ResponseDebugContext::from_headers(&headers(&[(alias, "req_123")]));
708 assert_eq!(debug.request_id.as_deref(), Some("req_123"), "{alias}");
709 }
710 let debug = ResponseDebugContext::from_headers(&headers(&[
712 ("anthropic-request-id", "anth"),
713 ("x-request-id", "xreq"),
714 ]));
715 assert_eq!(debug.request_id.as_deref(), Some("xreq"));
716 let debug = ResponseDebugContext::from_headers(&headers(&[("cf-ray", "8f3a-EWR")]));
718 assert_eq!(debug.cf_ray.as_deref(), Some("8f3a-EWR"));
719 assert!(debug.request_id.is_none());
720 assert!(ResponseDebugContext::from_headers(&headers(&[])).is_empty());
721 }
722
723 #[test]
724 fn user_facing_appends_ids_display_does_not() {
725 let debug = ResponseDebugContext {
726 request_id: Some("req_abc".to_string()),
727 cf_ray: Some("ray_1".to_string()),
728 };
729 let err = ModelError::Backend(BackendError::HttpError {
730 status: 500,
731 message: "boom".to_string(),
732 debug: debug.clone(),
733 });
734 let ufe = err.to_user_facing();
735 assert!(
736 ufe.message
737 .ends_with("(request-id: req_abc, cf-ray: ray_1)"),
738 "got: {}",
739 ufe.message
740 );
741 assert!(!err.to_string().contains("req_abc"));
743
744 let err = ModelError::Backend(BackendError::ProviderError {
745 provider: "anthropic".to_string(),
746 code: Some("api_error".to_string()),
747 message: "boom".to_string(),
748 debug,
749 });
750 let ufe = err.to_user_facing();
751 assert!(ufe.message.contains("(request-id: req_abc, cf-ray: ray_1)"));
752 assert!(!err.to_string().contains("req_abc"));
753
754 let err = ModelError::Backend(BackendError::HttpError {
756 status: 500,
757 message: "boom".to_string(),
758 debug: ResponseDebugContext::default(),
759 });
760 let msg = err.to_user_facing().message;
761 assert!(!msg.contains("request-id"));
762 assert!(!msg.ends_with('\n'));
763 }
764
765 #[test]
766 fn redaction_leaves_the_id_line_intact() {
767 let line = "HTTP 500: boom\n(request-id: req_0aF3kZ9xQ, cf-ray: 8f3ab2cd4e-EWR)";
770 assert_eq!(crate::utils::redact_secrets(line), line);
771 }
772
773 #[test]
774 fn timeout_display_omits_zero_duration() {
775 let err = ModelError::Timeout {
776 operation: "HTTP request".to_string(),
777 duration_secs: 0,
778 };
779 let rendered = err.to_string();
780 assert_eq!(rendered, "Operation 'HTTP request' timed out");
781 assert!(!rendered.contains("0 seconds"));
782 }
783
784 #[test]
785 fn timeout_display_shows_nonzero_duration() {
786 let err = ModelError::Timeout {
787 operation: "HTTP request".to_string(),
788 duration_secs: 45,
789 };
790 let rendered = err.to_string();
791 assert_eq!(
792 rendered,
793 "Operation 'HTTP request' timed out after 45 seconds"
794 );
795 }
796
797 #[test]
798 fn timeout_user_facing_omits_zero_duration() {
799 let err = ModelError::Timeout {
800 operation: "HTTP request".to_string(),
801 duration_secs: 0,
802 };
803 let ufe = err.to_user_facing();
804 assert_eq!(ufe.message, "'HTTP request' timed out");
805 assert!(!ufe.message.contains("0 seconds"));
806 }
807
808 #[test]
809 fn extract_error_handles_ollama_string_shape() {
810 let body = r#"{"error":"Internal Server Error (ref: 6e8ae4c7)"}"#;
811 assert_eq!(
812 try_extract_error_message(body).as_deref(),
813 Some("Internal Server Error (ref: 6e8ae4c7)")
814 );
815 }
816
817 #[test]
818 fn extract_error_handles_openai_object_shape_with_type() {
819 let body = r#"{"error":{"message":"Rate limit","type":"rate_limit_error","code":null}}"#;
820 assert_eq!(
821 try_extract_error_message(body).as_deref(),
822 Some("rate_limit_error: Rate limit")
823 );
824 }
825
826 #[test]
829 fn extract_error_handles_openrouter_numeric_code() {
830 let body = r#"{"error":{"message":"upstream timeout","code":504,"metadata":{}}}"#;
831 assert_eq!(
832 try_extract_error_message(body).as_deref(),
833 Some("upstream timeout")
834 );
835 }
836
837 #[test]
838 fn extract_error_returns_none_for_non_json() {
839 assert_eq!(try_extract_error_message("<html>bad gateway</html>"), None);
840 assert_eq!(try_extract_error_message(""), None);
841 assert_eq!(try_extract_error_message("plain text error"), None);
842 }
843
844 #[test]
845 fn extract_error_returns_none_for_missing_error_field() {
846 let body = r#"{"status":"ok","message":"nothing here"}"#;
847 assert_eq!(try_extract_error_message(body), None);
848 }
849
850 #[test]
855 fn http_500_renders_clean_message_and_temporary_category() {
856 let err = ModelError::Backend(BackendError::HttpError {
857 status: 500,
858 message: r#"{"error":"Internal Server Error (ref: abc-123)"}"#.to_string(),
859 debug: Default::default(),
860 });
861 let ufe = err.to_user_facing();
862 assert_eq!(ufe.summary, "Server error");
863 assert_eq!(
864 ufe.message,
865 "HTTP 500: Internal Server Error (ref: abc-123)"
866 );
867 assert!(ufe.recoverable);
868 assert_eq!(ufe.category, ErrorCategory::Temporary);
869 }
870
871 #[test]
874 fn http_500_falls_back_to_raw_body_for_html() {
875 let err = ModelError::Backend(BackendError::HttpError {
876 status: 502,
877 message: "<html>Bad Gateway</html>".to_string(),
878 debug: Default::default(),
879 });
880 let ufe = err.to_user_facing();
881 assert_eq!(ufe.message, "HTTP 502: <html>Bad Gateway</html>");
882 }
883}