Skip to main content

mermaid_model/models/
error.rs

1//! Comprehensive error types for the model system
2//!
3//! Replaces scattered `anyhow::Error` usage with structured, actionable errors
4//! that enable proper recovery, retry logic, and user-friendly messages.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// User-facing error information with actionable suggestions
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UserFacingError {
12    /// Short summary for status bar (e.g., "Connection failed")
13    pub summary: String,
14    /// Detailed message for chat display
15    pub message: String,
16    /// Actionable suggestion for the user
17    pub suggestion: String,
18    /// Error category for styling/icons
19    pub category: ErrorCategory,
20    /// Whether this error is recoverable (user can retry)
21    pub recoverable: bool,
22}
23
24/// Error categories for visual differentiation
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ErrorCategory {
27    /// Connection/network issues
28    Connection,
29    /// Authentication/authorization issues
30    Auth,
31    /// Configuration issues
32    Config,
33    /// Resource not found
34    NotFound,
35    /// Temporary issue (rate limit, timeout)
36    Temporary,
37    /// Internal/unexpected error
38    Internal,
39}
40
41/// Top-level error type for all model operations
42#[derive(Debug)]
43pub enum ModelError {
44    /// Backend-specific error (connection, API, etc)
45    Backend(BackendError),
46
47    /// Configuration error (invalid settings, missing keys, etc)
48    Config(ConfigError),
49
50    /// Model not found or unavailable
51    ModelNotFound {
52        model: String,
53        searched: Vec<String>,
54    },
55
56    /// Request timeout
57    Timeout {
58        operation: String,
59        duration_secs: u64,
60    },
61
62    /// Rate limit exceeded. `retry_after` is the server's `Retry-After` in
63    /// seconds when it sent one; `message` is the human-readable reason from
64    /// the 429 response body when one could be extracted (e.g. Cloudflare's
65    /// "used up your daily free allocation of 10,000 neurons") — the
66    /// difference between "wait a moment" and "upgrade your plan".
67    RateLimit {
68        retry_after: Option<u64>,
69        message: Option<String>,
70    },
71
72    /// Invalid request (malformed input, bad parameters)
73    InvalidRequest(String),
74
75    /// Response parsing error
76    ParseError {
77        message: String,
78        raw: Option<String>,
79    },
80
81    /// Stream error (connection dropped, incomplete response)
82    StreamError(String),
83
84    /// Authentication error
85    Authentication(String),
86
87    /// The adapter does not implement the requested feature (e.g. an
88    /// Anthropic adapter has no `list_models` endpoint, so the trait's
89    /// default impl returns this).
90    Unsupported { feature: String },
91
92    /// The provider call was aborted by the turn's cancellation
93    /// token. The effect runner swallows this silently — the
94    /// terminal `Msg::TurnCancelled` is emitted from `drop_scope`
95    /// after the scope's `JoinSet` drains, so no `UpstreamError`
96    /// should reach the reducer for cancelled turns.
97    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    /// Convert to user-facing error with actionable suggestions
161    #[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                // Body may be a raw JSON blob from the provider (e.g., Ollama
219                // Cloud emits `{"error":"Internal Server Error (ref: ...)"}`).
220                // Render the extracted message when we can, fall back to the
221                // raw body so we never lose information.
222                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                    // 5xx errors ARE recoverable (the caller can retry) and
231                    // the suggestion tells the user to try again — that's
232                    // the `Temporary` category semantic. `Internal` was
233                    // wrong and painted the status bar with a sterner tone
234                    // than the situation warrants.
235                    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                    // Prefer the provider's own explanation (it distinguishes
333                    // "slow down" from "your daily quota is spent") over the
334                    // generic phrasing.
335                    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/// Correlation ids captured from a provider's HTTP response headers.
396/// Appended (one plain-text line) to the user-facing error message so bug
397/// reports to the provider can quote them; deliberately NOT part of
398/// `Display`, which feeds logs and `try_extract_error_message`.
399#[derive(Debug, Default, Clone, PartialEq, Eq)]
400pub struct ResponseDebugContext {
401    /// Provider request id: first present of `x-request-id`, `request-id`,
402    /// `anthropic-request-id`.
403    pub request_id: Option<String>,
404    /// Cloudflare ray id (`cf-ray`) — identifies the edge PoP + request for
405    /// providers fronted by Cloudflare.
406    pub cf_ray: Option<String>,
407}
408
409impl ResponseDebugContext {
410    /// Capture correlation ids from response headers. Cheap; call before
411    /// consuming the body (`.text()` takes the response by value).
412    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            // Feeds the TRACE ring so `--trace` runs correlate provider-side.
428            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    /// The `(request-id: ..., cf-ray: ...)` suffix line, or `None` when
443    /// nothing was captured.
444    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    /// Append the rendered id line to a user-facing message when present.
462    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/// Backend-specific errors
471#[derive(Debug)]
472pub enum BackendError {
473    /// Connection failed (network, DNS, etc)
474    ConnectionFailed {
475        backend: String,
476        url: String,
477        reason: String,
478    },
479
480    /// Backend not available (not running, health check failed)
481    NotAvailable { backend: String, reason: String },
482
483    /// HTTP error from backend
484    HttpError {
485        status: u16,
486        message: String,
487        /// Response-header correlation ids (empty when the error was not
488        /// built from an HTTP response).
489        debug: ResponseDebugContext,
490    },
491
492    /// Backend returned unexpected response format
493    UnexpectedResponse { backend: String, message: String },
494
495    /// Provider-specific error
496    ProviderError {
497        provider: String,
498        code: Option<String>,
499        message: String,
500        /// Response-header correlation ids (empty when the error was not
501        /// built from an HTTP response).
502        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            // `debug` ids are deliberately NOT printed here: Display feeds
520            // logs and try_extract_error_message; the ids surface once, in
521            // to_user_facing.
522            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/// Configuration errors
549#[derive(Debug)]
550pub enum ConfigError {
551    /// Missing required configuration
552    MissingRequired(String),
553
554    /// Invalid value for configuration
555    InvalidValue {
556        field: String,
557        value: String,
558        reason: String,
559    },
560
561    /// File operation error (read, parse, etc)
562    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
587/// Result type alias for model operations
588pub type Result<T> = std::result::Result<T, ModelError>;
589
590/// Conversion from `anyhow::Error` (for gradual migration)
591impl From<anyhow::Error> for ModelError {
592    fn from(err: anyhow::Error) -> Self {
593        Self::InvalidRequest(err.to_string())
594    }
595}
596
597/// Conversion from `reqwest::Error`
598impl From<reqwest::Error> for ModelError {
599    fn from(err: reqwest::Error) -> Self {
600        if err.is_timeout() {
601            // reqwest::Error doesn't expose the actual elapsed duration,
602            // and the adapter only sets a connect_timeout (no global
603            // request timeout), so there is no truthful number to report.
604            // 0 is a sentinel meaning "unknown" — the Display and
605            // to_user_facing impls for ModelError::Timeout omit the
606            // "after N seconds" suffix when duration_secs == 0.
607            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
636/// Conversion from `serde_json::Error`
637impl 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
646/// Try to extract a human-readable error message from a raw upstream
647/// response body. Handles the two shapes observed in the wild across
648/// Ollama, OpenAI, Groq, OpenRouter, Cerebras, DeepInfra, Together
649/// (Anthropic + Gemini have their own adapter-level parsers):
650///
651/// - `{"error": "some string"}` — Ollama Cloud style
652/// - `{"error": {"message": "...", ...}}` — OpenAI Chat Completions style
653///
654/// Returns `None` when the body isn't parseable JSON or doesn't match
655/// either shape — callers fall back to the raw body so no information
656/// is lost.
657fn 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    // Shape 1: `error` is a plain string.
666    if let Some(s) = error.as_str() {
667        return Some(s.trim().to_string());
668    }
669
670    // Shape 2: `error` is an object with a `message` field. Prepend
671    // `type:` if present (matches OpenAI's `"invalid_request_error"` +
672    // message convention).
673    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        // Precedence: x-request-id beats the later aliases.
711        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        // cf-ray captured independently; absent headers -> empty.
717        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        // Display feeds logs + try_extract_error_message: no ids there.
742        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        // Empty debug adds nothing (no trailing blank line).
755        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        // The `(request-id: ...)` line must survive the secret scrubber —
768        // pinned so a future redaction pattern can't silently eat it.
769        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    /// OpenRouter emits `code` as a numeric HTTP status, not a string.
827    /// `as_str()` returns None so we skip the prefix gracefully.
828    #[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    /// 5xx responses carrying an Ollama-style JSON body should render as
851    /// the clean string in the user-facing message, and be categorised as
852    /// `Temporary` (matches `recoverable: true`) so the status bar treats
853    /// them as "come back and retry" rather than "something is broken".
854    #[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    /// Unparseable bodies fall back to the raw content so we never lose
872    /// information.
873    #[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}