Skip to main content

mermaid_cli/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            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    /// Convert to user-facing error with actionable suggestions
162    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                // Body may be a raw JSON blob from the provider (e.g., Ollama
218                // Cloud emits `{"error":"Internal Server Error (ref: ...)"}`).
219                // Render the extracted message when we can, fall back to the
220                // raw body so we never lose information.
221                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                    // 5xx errors ARE recoverable (the caller can retry) and
230                    // the suggestion tells the user to try again — that's
231                    // the `Temporary` category semantic. `Internal` was
232                    // wrong and painted the status bar with a sterner tone
233                    // than the situation warrants.
234                    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                    // Prefer the provider's own explanation (it distinguishes
338                    // "slow down" from "your daily quota is spent") over the
339                    // generic phrasing.
340                    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/// Correlation ids captured from a provider's HTTP response headers.
402/// Appended (one plain-text line) to the user-facing error message so bug
403/// reports to the provider can quote them; deliberately NOT part of
404/// `Display`, which feeds logs and `try_extract_error_message`.
405#[derive(Debug, Default, Clone, PartialEq, Eq)]
406pub struct ResponseDebugContext {
407    /// Provider request id: first present of `x-request-id`, `request-id`,
408    /// `anthropic-request-id`.
409    pub request_id: Option<String>,
410    /// Cloudflare ray id (`cf-ray`) — identifies the edge PoP + request for
411    /// providers fronted by Cloudflare.
412    pub cf_ray: Option<String>,
413}
414
415impl ResponseDebugContext {
416    /// Capture correlation ids from response headers. Cheap; call before
417    /// consuming the body (`.text()` takes the response by value).
418    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            // Feeds the TRACE ring so `--trace` runs correlate provider-side.
434            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    /// The `(request-id: ..., cf-ray: ...)` suffix line, or `None` when
448    /// nothing was captured.
449    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    /// Append the rendered id line to a user-facing message when present.
467    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/// Backend-specific errors
476#[derive(Debug)]
477pub enum BackendError {
478    /// Connection failed (network, DNS, etc)
479    ConnectionFailed {
480        backend: String,
481        url: String,
482        reason: String,
483    },
484
485    /// Backend not available (not running, health check failed)
486    NotAvailable { backend: String, reason: String },
487
488    /// HTTP error from backend
489    HttpError {
490        status: u16,
491        message: String,
492        /// Response-header correlation ids (empty when the error was not
493        /// built from an HTTP response).
494        debug: ResponseDebugContext,
495    },
496
497    /// Backend returned unexpected response format
498    UnexpectedResponse { backend: String, message: String },
499
500    /// Provider-specific error
501    ProviderError {
502        provider: String,
503        code: Option<String>,
504        message: String,
505        /// Response-header correlation ids (empty when the error was not
506        /// built from an HTTP response).
507        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            // `debug` ids are deliberately NOT printed here: Display feeds
525            // logs and try_extract_error_message; the ids surface once, in
526            // to_user_facing.
527            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/// Configuration errors
554#[derive(Debug)]
555pub enum ConfigError {
556    /// Missing required configuration
557    MissingRequired(String),
558
559    /// Invalid value for configuration
560    InvalidValue {
561        field: String,
562        value: String,
563        reason: String,
564    },
565
566    /// File operation error (read, parse, etc)
567    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
592/// Result type alias for model operations
593pub type Result<T> = std::result::Result<T, ModelError>;
594
595/// Conversion from anyhow::Error (for gradual migration)
596impl From<anyhow::Error> for ModelError {
597    fn from(err: anyhow::Error) -> Self {
598        ModelError::InvalidRequest(err.to_string())
599    }
600}
601
602/// Conversion from reqwest::Error
603impl From<reqwest::Error> for ModelError {
604    fn from(err: reqwest::Error) -> Self {
605        if err.is_timeout() {
606            // reqwest::Error doesn't expose the actual elapsed duration,
607            // and the adapter only sets a connect_timeout (no global
608            // request timeout), so there is no truthful number to report.
609            // 0 is a sentinel meaning "unknown" — the Display and
610            // to_user_facing impls for ModelError::Timeout omit the
611            // "after N seconds" suffix when duration_secs == 0.
612            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
641/// Conversion from serde_json::Error
642impl 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
651/// Try to extract a human-readable error message from a raw upstream
652/// response body. Handles the two shapes observed in the wild across
653/// Ollama, OpenAI, Groq, OpenRouter, Cerebras, DeepInfra, Together
654/// (Anthropic + Gemini have their own adapter-level parsers):
655///
656/// - `{"error": "some string"}` — Ollama Cloud style
657/// - `{"error": {"message": "...", ...}}` — OpenAI Chat Completions style
658///
659/// Returns `None` when the body isn't parseable JSON or doesn't match
660/// either shape — callers fall back to the raw body so no information
661/// is lost.
662fn 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    // Shape 1: `error` is a plain string.
671    if let Some(s) = error.as_str() {
672        return Some(s.trim().to_string());
673    }
674
675    // Shape 2: `error` is an object with a `message` field. Prepend
676    // `type:` if present (matches OpenAI's `"invalid_request_error"` +
677    // message convention).
678    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        // Precedence: x-request-id beats the later aliases.
716        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        // cf-ray captured independently; absent headers -> empty.
722        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        // Display feeds logs + try_extract_error_message: no ids there.
747        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        // Empty debug adds nothing (no trailing blank line).
760        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        // The `(request-id: ...)` line must survive the secret scrubber —
773        // pinned so a future redaction pattern can't silently eat it.
774        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    /// OpenRouter emits `code` as a numeric HTTP status, not a string.
832    /// `as_str()` returns None so we skip the prefix gracefully.
833    #[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    /// 5xx responses carrying an Ollama-style JSON body should render as
856    /// the clean string in the user-facing message, and be categorised as
857    /// `Temporary` (matches `recoverable: true`) so the status bar treats
858    /// them as "come back and retry" rather than "something is broken".
859    #[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    /// Unparseable bodies fall back to the raw content so we never lose
877    /// information.
878    #[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}