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