Skip to main content

autoagents_llm/
error.rs

1use std::fmt;
2use std::time::Duration;
3
4/// Maximum bytes of a provider response body included in [`Display`](fmt::Display) output.
5pub const MAX_ERROR_BODY_DISPLAY_BYTES: usize = 512;
6
7/// Phase where a guardrail violation occurred.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum GuardrailPhase {
10    Input,
11    Output,
12}
13
14/// Error types that can occur when interacting with LLM providers.
15#[derive(Clone)]
16pub enum LLMError {
17    /// HTTP transport failures (connection, timeout, DNS, etc.).
18    HttpError(String),
19    /// Authentication and authorization errors (missing local API key or HTTP 401/403).
20    AuthError {
21        message: String,
22        status_code: Option<u16>,
23        response_body: Option<Box<str>>,
24    },
25    /// Rate limit or provider overload (HTTP 429, 529).
26    RateLimitError {
27        status_code: u16,
28        message: String,
29        response_body: Box<str>,
30        retry_after: Option<Duration>,
31        provider_code: Option<Box<str>>,
32    },
33    /// Non-success HTTP response that is not auth or rate-limit.
34    HttpStatusError {
35        status_code: u16,
36        message: String,
37        response_body: Box<str>,
38        retry_after: Option<Duration>,
39        provider_code: Option<Box<str>>,
40    },
41    /// Invalid request parameters, format, or client-side HTTP 4xx rejection.
42    InvalidRequest {
43        message: String,
44        status_code: Option<u16>,
45        response_body: Option<Box<str>>,
46    },
47    /// Errors returned by the LLM provider in a parsed response payload.
48    ProviderError(String),
49    /// API response parsing or format error on a successful HTTP response.
50    ResponseFormatError {
51        message: String,
52        raw_response: String,
53    },
54    /// Generic error (unsupported features, internal stubs).
55    Generic(String),
56    /// JSON serialization/deserialization errors.
57    JsonError(String),
58    /// Tool configuration error.
59    ToolConfigError(String),
60    /// Provider does not support tool calling.
61    NoToolSupport(String),
62    /// Guardrail blocked the request/response.
63    GuardrailBlocked {
64        phase: GuardrailPhase,
65        guard: Box<str>,
66        rule_id: Box<str>,
67        category: Box<str>,
68        severity: Box<str>,
69        message: Box<str>,
70    },
71    /// Guardrail execution failed unexpectedly.
72    GuardrailExecutionFailed { guard: String, message: String },
73}
74
75impl LLMError {
76    /// Local configuration error when an API key is missing.
77    pub fn missing_api_key(message: impl Into<String>) -> Self {
78        Self::AuthError {
79            message: message.into(),
80            status_code: None,
81            response_body: None,
82        }
83    }
84
85    /// Local validation error for invalid request parameters or configuration.
86    pub fn invalid_request(message: impl Into<String>) -> Self {
87        Self::InvalidRequest {
88            message: message.into(),
89            status_code: None,
90            response_body: None,
91        }
92    }
93
94    /// Returns `true` when the error represents a transient failure worth retrying.
95    pub fn is_retryable(&self) -> bool {
96        is_retryable(self)
97    }
98
99    /// HTTP status code when the error originated from a non-success response.
100    pub fn http_status_code(&self) -> Option<u16> {
101        match self {
102            Self::AuthError { status_code, .. } => *status_code,
103            Self::RateLimitError { status_code, .. } => Some(*status_code),
104            Self::HttpStatusError { status_code, .. } => Some(*status_code),
105            Self::InvalidRequest { status_code, .. } => *status_code,
106            _ => None,
107        }
108    }
109
110    /// Provider response body attached to HTTP-related errors.
111    pub fn response_body(&self) -> Option<&str> {
112        match self {
113            Self::AuthError { response_body, .. } => response_body.as_deref(),
114            Self::RateLimitError { response_body, .. } => Some(response_body),
115            Self::HttpStatusError { response_body, .. } => Some(response_body),
116            Self::InvalidRequest { response_body, .. } => response_body.as_deref(),
117            Self::ResponseFormatError { raw_response, .. } => Some(raw_response),
118            _ => None,
119        }
120    }
121
122    /// Returns `true` when this `HttpError` represents a retryable transport failure.
123    pub fn is_transport_retryable(&self) -> bool {
124        match self {
125            Self::HttpError(msg) => is_transport_retryable_message(msg),
126            _ => false,
127        }
128    }
129}
130
131/// Returns `true` when a transport error message represents a retryable failure.
132pub fn is_transport_retryable_message(message: &str) -> bool {
133    let m = message.to_ascii_lowercase();
134    m.starts_with("request timed out:")
135        || m.starts_with("connection failed:")
136        || m.contains("connection reset")
137        || m.contains("broken pipe")
138        || m.contains("dns error")
139        || m.contains("dns lookup")
140        || m.contains("name or service not known")
141}
142
143/// Truncates a response body for safe inclusion in log-oriented display output.
144pub fn truncate_for_display(body: &str) -> String {
145    if body.len() <= MAX_ERROR_BODY_DISPLAY_BYTES {
146        return body.to_string();
147    }
148
149    let mut end = MAX_ERROR_BODY_DISPLAY_BYTES;
150    while end > 0 && !body.is_char_boundary(end) {
151        end -= 1;
152    }
153
154    format!(
155        "{}... [truncated, {} bytes total]",
156        &body[..end],
157        body.len()
158    )
159}
160
161fn write_truncated_body(f: &mut fmt::Formatter<'_>, label: &str, body: &str) -> fmt::Result {
162    write!(f, ". {label}: {}", truncate_for_display(body))
163}
164
165fn debug_optional_body(body: &Option<Box<str>>) -> Option<String> {
166    body.as_deref().map(truncate_for_display)
167}
168
169impl fmt::Debug for LLMError {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        match self {
172            Self::HttpError(message) => f.debug_tuple("HttpError").field(message).finish(),
173            Self::AuthError {
174                message,
175                status_code,
176                response_body,
177            } => f
178                .debug_struct("AuthError")
179                .field("message", message)
180                .field("status_code", status_code)
181                .field("response_body", &debug_optional_body(response_body))
182                .finish(),
183            Self::RateLimitError {
184                status_code,
185                message,
186                response_body,
187                retry_after,
188                provider_code,
189            } => f
190                .debug_struct("RateLimitError")
191                .field("status_code", status_code)
192                .field("message", message)
193                .field("response_body", &truncate_for_display(response_body))
194                .field("retry_after", retry_after)
195                .field("provider_code", provider_code)
196                .finish(),
197            Self::HttpStatusError {
198                status_code,
199                message,
200                response_body,
201                retry_after,
202                provider_code,
203            } => f
204                .debug_struct("HttpStatusError")
205                .field("status_code", status_code)
206                .field("message", message)
207                .field("response_body", &truncate_for_display(response_body))
208                .field("retry_after", retry_after)
209                .field("provider_code", provider_code)
210                .finish(),
211            Self::InvalidRequest {
212                message,
213                status_code,
214                response_body,
215            } => f
216                .debug_struct("InvalidRequest")
217                .field("message", message)
218                .field("status_code", status_code)
219                .field("response_body", &debug_optional_body(response_body))
220                .finish(),
221            Self::ProviderError(message) => f.debug_tuple("ProviderError").field(message).finish(),
222            Self::ResponseFormatError {
223                message,
224                raw_response,
225            } => f
226                .debug_struct("ResponseFormatError")
227                .field("message", message)
228                .field("raw_response", &truncate_for_display(raw_response))
229                .finish(),
230            Self::Generic(message) => f.debug_tuple("Generic").field(message).finish(),
231            Self::JsonError(message) => f.debug_tuple("JsonError").field(message).finish(),
232            Self::ToolConfigError(message) => {
233                f.debug_tuple("ToolConfigError").field(message).finish()
234            }
235            Self::NoToolSupport(message) => f.debug_tuple("NoToolSupport").field(message).finish(),
236            Self::GuardrailBlocked {
237                phase,
238                guard,
239                rule_id,
240                category,
241                severity,
242                message,
243            } => f
244                .debug_struct("GuardrailBlocked")
245                .field("phase", phase)
246                .field("guard", guard)
247                .field("rule_id", rule_id)
248                .field("category", category)
249                .field("severity", severity)
250                .field("message", message)
251                .finish(),
252            Self::GuardrailExecutionFailed { guard, message } => f
253                .debug_struct("GuardrailExecutionFailed")
254                .field("guard", guard)
255                .field("message", message)
256                .finish(),
257        }
258    }
259}
260
261/// Returns `true` when an HTTP status code represents a retryable server/transient error.
262pub fn is_http_status_retryable(status_code: u16) -> bool {
263    matches!(status_code, 408 | 500..=599)
264}
265
266/// Default retryability predicate shared by the retry layer.
267pub fn is_retryable(err: &LLMError) -> bool {
268    match err {
269        LLMError::RateLimitError { .. } => true,
270        LLMError::HttpStatusError { status_code, .. } => is_http_status_retryable(*status_code),
271        LLMError::HttpError(msg) => is_transport_retryable_message(msg),
272        LLMError::Generic(_)
273        | LLMError::AuthError { .. }
274        | LLMError::InvalidRequest { .. }
275        | LLMError::GuardrailBlocked { .. }
276        | LLMError::GuardrailExecutionFailed { .. }
277        | LLMError::ResponseFormatError { .. }
278        | LLMError::JsonError(_)
279        | LLMError::ToolConfigError(_)
280        | LLMError::NoToolSupport(_)
281        | LLMError::ProviderError(_) => false,
282    }
283}
284
285/// Default fallbackability predicate shared by the fallback layer.
286///
287/// Unlike [`is_retryable`], every [`LLMError::HttpError`] is fallbackable regardless
288/// of message content so callers can route any transport failure to a backup provider.
289/// Retryability still requires a transport-failure message via
290/// [`is_transport_retryable_message`].
291pub fn is_fallbackable(err: &LLMError) -> bool {
292    match err {
293        LLMError::RateLimitError { .. } => true,
294        LLMError::HttpStatusError { status_code, .. } => is_http_status_retryable(*status_code),
295        LLMError::HttpError(_) => true,
296        LLMError::ProviderError(_)
297        | LLMError::ResponseFormatError { .. }
298        | LLMError::NoToolSupport(_)
299        | LLMError::Generic(_) => true,
300        LLMError::AuthError { .. }
301        | LLMError::InvalidRequest { .. }
302        | LLMError::JsonError(_)
303        | LLMError::ToolConfigError(_)
304        | LLMError::GuardrailBlocked { .. }
305        | LLMError::GuardrailExecutionFailed { .. } => false,
306    }
307}
308
309fn write_status_prefixed_error(
310    f: &mut fmt::Formatter<'_>,
311    label: &str,
312    message: &str,
313    status_code: Option<u16>,
314    response_body: Option<&str>,
315) -> fmt::Result {
316    if let Some(status) = status_code {
317        write!(f, "{label} ({status}): {message}")?;
318    } else {
319        write!(f, "{label}: {message}")?;
320    }
321    if let Some(body) = response_body {
322        write_truncated_body(f, "Response", body)?;
323    }
324    Ok(())
325}
326
327fn display_auth_error(
328    f: &mut fmt::Formatter<'_>,
329    message: &str,
330    status_code: Option<u16>,
331    response_body: Option<&str>,
332) -> fmt::Result {
333    write_status_prefixed_error(f, "Auth Error", message, status_code, response_body)
334}
335
336fn display_rate_limit_error(
337    f: &mut fmt::Formatter<'_>,
338    status_code: u16,
339    message: &str,
340    response_body: &str,
341    retry_after: Option<Duration>,
342    provider_code: Option<&str>,
343) -> fmt::Result {
344    write!(f, "Rate Limit Error ({status_code}): {message}")?;
345    write_truncated_body(f, "Response", response_body)?;
346    if let Some(code) = provider_code {
347        write!(f, ". Provider code: {code}")?;
348    }
349    if let Some(retry_after) = retry_after {
350        write!(f, ". Retry-After: {}s", retry_after.as_secs())?;
351    }
352    Ok(())
353}
354
355fn display_http_status_error(
356    f: &mut fmt::Formatter<'_>,
357    status_code: u16,
358    message: &str,
359    response_body: &str,
360    retry_after: Option<Duration>,
361    provider_code: Option<&str>,
362) -> fmt::Result {
363    write!(f, "HTTP Status Error ({status_code}): {message}")?;
364    write_truncated_body(f, "Response", response_body)?;
365    if let Some(code) = provider_code {
366        write!(f, ". Provider code: {code}")?;
367    }
368    if let Some(retry_after) = retry_after {
369        write!(f, ". Retry-After: {}s", retry_after.as_secs())?;
370    }
371    Ok(())
372}
373
374fn display_invalid_request(
375    f: &mut fmt::Formatter<'_>,
376    message: &str,
377    status_code: Option<u16>,
378    response_body: Option<&str>,
379) -> fmt::Result {
380    write_status_prefixed_error(f, "Invalid Request", message, status_code, response_body)
381}
382
383fn display_response_format_error(
384    f: &mut fmt::Formatter<'_>,
385    message: &str,
386    raw_response: &str,
387) -> fmt::Result {
388    write!(f, "Response Format Error: {message}")?;
389    write_truncated_body(f, "Raw response", raw_response)
390}
391
392fn display_guardrail_blocked(
393    f: &mut fmt::Formatter<'_>,
394    phase: GuardrailPhase,
395    guard: &str,
396    rule_id: &str,
397    category: &str,
398    severity: &str,
399    message: &str,
400) -> fmt::Result {
401    let phase = match phase {
402        GuardrailPhase::Input => "input",
403        GuardrailPhase::Output => "output",
404    };
405    write!(
406        f,
407        "guardrail blocked {phase}: guard={guard}, rule={rule_id}, category={category}, severity={severity}, message={message}"
408    )
409}
410
411impl fmt::Display for LLMError {
412    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
413        match self {
414            LLMError::HttpError(e) => write!(f, "HTTP Error: {e}"),
415            LLMError::AuthError {
416                message,
417                status_code,
418                response_body,
419            } => display_auth_error(f, message, *status_code, response_body.as_deref()),
420            LLMError::RateLimitError {
421                status_code,
422                message,
423                response_body,
424                retry_after,
425                provider_code,
426            } => display_rate_limit_error(
427                f,
428                *status_code,
429                message,
430                response_body,
431                *retry_after,
432                provider_code.as_deref(),
433            ),
434            LLMError::HttpStatusError {
435                status_code,
436                message,
437                response_body,
438                retry_after,
439                provider_code,
440            } => display_http_status_error(
441                f,
442                *status_code,
443                message,
444                response_body,
445                *retry_after,
446                provider_code.as_deref(),
447            ),
448            LLMError::InvalidRequest {
449                message,
450                status_code,
451                response_body,
452            } => display_invalid_request(f, message, *status_code, response_body.as_deref()),
453            LLMError::ProviderError(e) => write!(f, "Provider Error: {e}"),
454            LLMError::Generic(e) => write!(f, "Generic Error : {e}"),
455            LLMError::ResponseFormatError {
456                message,
457                raw_response,
458            } => display_response_format_error(f, message, raw_response),
459            LLMError::JsonError(e) => write!(f, "JSON Parse Error: {e}"),
460            LLMError::ToolConfigError(e) => write!(f, "Tool Configuration Error: {e}"),
461            LLMError::NoToolSupport(e) => write!(f, "No Tool Support: {e}"),
462            LLMError::GuardrailBlocked {
463                phase,
464                guard,
465                rule_id,
466                category,
467                severity,
468                message,
469            } => display_guardrail_blocked(f, *phase, guard, rule_id, category, severity, message),
470            LLMError::GuardrailExecutionFailed { guard, message } => write!(
471                f,
472                "guardrail execution failed: guard={guard}, error={message}"
473            ),
474        }
475    }
476}
477
478impl std::error::Error for LLMError {}
479
480/// Converts reqwest HTTP errors into LLMErrors, preserving transport context.
481#[cfg(not(target_arch = "wasm32"))]
482impl From<reqwest::Error> for LLMError {
483    fn from(err: reqwest::Error) -> Self {
484        if err.is_timeout() {
485            LLMError::HttpError(format!("request timed out: {err}"))
486        } else if err.is_connect() {
487            LLMError::HttpError(format!("connection failed: {err}"))
488        } else {
489            LLMError::HttpError(err.to_string())
490        }
491    }
492}
493
494impl From<serde_json::Error> for LLMError {
495    fn from(err: serde_json::Error) -> Self {
496        LLMError::JsonError(format!(
497            "{} at line {} column {}",
498            err,
499            err.line(),
500            err.column()
501        ))
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use serde_json::Error as JsonError;
509
510    #[test]
511    fn test_truncate_for_display_short_body_unchanged() {
512        assert_eq!(truncate_for_display("short"), "short");
513    }
514
515    #[test]
516    fn test_truncate_for_display_long_body() {
517        let body = "x".repeat(MAX_ERROR_BODY_DISPLAY_BYTES + 10);
518        let truncated = truncate_for_display(&body);
519        assert!(truncated.contains("truncated"));
520        assert!(truncated.starts_with(&"x".repeat(MAX_ERROR_BODY_DISPLAY_BYTES)));
521    }
522
523    #[test]
524    fn test_is_transport_retryable_message_prefixed_forms() {
525        assert!(is_transport_retryable_message(
526            "request timed out: operation timed out"
527        ));
528        assert!(is_transport_retryable_message(
529            "connection failed: tcp connect error"
530        ));
531        assert!(!is_transport_retryable_message(
532            "HTTP Status Error (400): bad request"
533        ));
534    }
535
536    #[test]
537    fn test_llm_error_display_auth_error_with_status_truncates_body() {
538        let body = "secret".repeat(200);
539        let error = LLMError::AuthError {
540            message: "Unauthorized".to_string(),
541            status_code: Some(401),
542            response_body: Some(body.clone().into_boxed_str()),
543        };
544        let display = error.to_string();
545        assert!(display.contains("401"));
546        assert!(display.contains("truncated"));
547        assert_eq!(error.response_body(), Some(body.as_str()));
548    }
549
550    #[test]
551    fn test_llm_error_display_rate_limit_error_includes_status() {
552        let error = LLMError::RateLimitError {
553            status_code: 529,
554            message: "Overloaded".to_string(),
555            response_body: "overload".into(),
556            retry_after: Some(Duration::from_secs(30)),
557            provider_code: Some("overloaded".into()),
558        };
559        let display = error.to_string();
560        assert!(display.contains("529"));
561        assert!(display.contains("overloaded"));
562        assert_eq!(error.http_status_code(), Some(529));
563    }
564
565    #[test]
566    fn test_invalid_request_preserves_response_body() {
567        let err = LLMError::InvalidRequest {
568            message: "bad request".into(),
569            status_code: Some(400),
570            response_body: Some(r#"{"error":"details"}"#.into()),
571        };
572        assert_eq!(err.http_status_code(), Some(400));
573        assert_eq!(err.response_body(), Some(r#"{"error":"details"}"#));
574    }
575
576    #[test]
577    fn test_is_retryable_matrix() {
578        assert!(
579            LLMError::RateLimitError {
580                status_code: 429,
581                message: "limit".into(),
582                response_body: "body".into(),
583                retry_after: None,
584                provider_code: None,
585            }
586            .is_retryable()
587        );
588        assert!(
589            LLMError::HttpStatusError {
590                status_code: 503,
591                message: "down".into(),
592                response_body: "body".into(),
593                retry_after: None,
594                provider_code: None,
595            }
596            .is_retryable()
597        );
598        assert!(
599            !LLMError::HttpStatusError {
600                status_code: 400,
601                message: "bad".into(),
602                response_body: "body".into(),
603                retry_after: None,
604                provider_code: None,
605            }
606            .is_retryable()
607        );
608        assert!(!LLMError::Generic("unsupported".into()).is_retryable());
609        assert!(LLMError::HttpError("request timed out: elapsed".into()).is_retryable());
610    }
611
612    #[test]
613    fn test_llm_error_debug_truncates_response_body() {
614        let body = "secret".repeat(MAX_ERROR_BODY_DISPLAY_BYTES + 10);
615        let error = LLMError::RateLimitError {
616            status_code: 429,
617            message: "limit".into(),
618            response_body: body.clone().into_boxed_str(),
619            retry_after: None,
620            provider_code: None,
621        };
622        let debug = format!("{error:?}");
623        assert!(debug.contains("truncated"));
624        assert!(!debug.contains(&body));
625    }
626
627    #[test]
628    fn test_from_serde_json_error() {
629        let json_str = r#"{"invalid": json}"#;
630        let json_error: JsonError =
631            serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
632
633        let llm_error: LLMError = json_error.into();
634
635        match llm_error {
636            LLMError::JsonError(msg) => {
637                assert!(msg.contains("line"));
638                assert!(msg.contains("column"));
639            }
640            _ => panic!("Expected JsonError"),
641        }
642    }
643
644    #[test]
645    fn test_http_status_code_and_response_body_accessors() {
646        let auth = LLMError::AuthError {
647            message: "denied".into(),
648            status_code: Some(403),
649            response_body: Some("body".into()),
650        };
651        assert_eq!(auth.http_status_code(), Some(403));
652        assert_eq!(auth.response_body(), Some("body"));
653
654        let rate_limit = LLMError::RateLimitError {
655            status_code: 429,
656            message: "limit".into(),
657            response_body: "payload".into(),
658            retry_after: None,
659            provider_code: None,
660        };
661        assert_eq!(rate_limit.response_body(), Some("payload"));
662
663        let http_status = LLMError::HttpStatusError {
664            status_code: 502,
665            message: "bad gateway".into(),
666            response_body: "html".into(),
667            retry_after: None,
668            provider_code: None,
669        };
670        assert_eq!(http_status.http_status_code(), Some(502));
671        assert_eq!(http_status.response_body(), Some("html"));
672
673        let format_error = LLMError::ResponseFormatError {
674            message: "invalid json".into(),
675            raw_response: "not-json".into(),
676        };
677        assert_eq!(format_error.response_body(), Some("not-json"));
678        assert_eq!(LLMError::Generic("x".into()).http_status_code(), None);
679        assert_eq!(LLMError::JsonError("parse".into()).response_body(), None);
680    }
681
682    #[test]
683    fn test_is_transport_retryable_method() {
684        assert!(LLMError::HttpError("request timed out: elapsed".into()).is_transport_retryable());
685        assert!(!LLMError::Generic("timeout".into()).is_transport_retryable());
686    }
687
688    #[test]
689    fn test_truncate_for_display_respects_utf8_boundary() {
690        let body = format!("{}€{}", "a".repeat(511), "z".repeat(20));
691        let truncated = truncate_for_display(&body);
692        assert!(truncated.contains("truncated"));
693        assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
694    }
695
696    #[test]
697    fn test_is_fallbackable_matrix() {
698        assert!(is_fallbackable(&LLMError::HttpError("down".into())));
699        assert!(is_fallbackable(&LLMError::RateLimitError {
700            status_code: 429,
701            message: "limit".into(),
702            response_body: "body".into(),
703            retry_after: None,
704            provider_code: None,
705        }));
706        assert!(!is_fallbackable(&LLMError::missing_api_key("missing")));
707        assert!(!is_fallbackable(&LLMError::GuardrailBlocked {
708            phase: GuardrailPhase::Input,
709            guard: "g".into(),
710            rule_id: "r".into(),
711            category: "c".into(),
712            severity: "high".into(),
713            message: "blocked".into(),
714        }));
715    }
716
717    #[test]
718    fn test_llm_error_debug_covers_struct_variants() {
719        let cases: Vec<LLMError> = vec![
720            LLMError::HttpError("transport".into()),
721            LLMError::AuthError {
722                message: "denied".into(),
723                status_code: Some(401),
724                response_body: Some("secret".into()),
725            },
726            LLMError::HttpStatusError {
727                status_code: 500,
728                message: "fail".into(),
729                response_body: "body".into(),
730                retry_after: None,
731                provider_code: Some("internal".into()),
732            },
733            LLMError::InvalidRequest {
734                message: "bad".into(),
735                status_code: Some(422),
736                response_body: Some("details".into()),
737            },
738            LLMError::ProviderError("provider".into()),
739            LLMError::ResponseFormatError {
740                message: "parse".into(),
741                raw_response: "raw".into(),
742            },
743            LLMError::Generic("generic".into()),
744            LLMError::JsonError("json".into()),
745            LLMError::ToolConfigError("tool".into()),
746            LLMError::NoToolSupport("tools".into()),
747            LLMError::GuardrailBlocked {
748                phase: GuardrailPhase::Output,
749                guard: "guard".into(),
750                rule_id: "rule".into(),
751                category: "cat".into(),
752                severity: "low".into(),
753                message: "msg".into(),
754            },
755            LLMError::GuardrailExecutionFailed {
756                guard: "guard".into(),
757                message: "runtime".into(),
758            },
759        ];
760
761        for error in cases {
762            let debug = format!("{error:?}");
763            assert!(!debug.is_empty());
764        }
765    }
766
767    #[test]
768    fn test_llm_error_display_covers_remaining_variants() {
769        assert!(
770            LLMError::HttpError("transport".into())
771                .to_string()
772                .contains("HTTP Error")
773        );
774
775        let status = LLMError::HttpStatusError {
776            status_code: 500,
777            message: "fail".into(),
778            response_body: "body".into(),
779            retry_after: Some(Duration::from_secs(120)),
780            provider_code: Some("INTERNAL".into()),
781        };
782        let status_display = status.to_string();
783        assert!(status_display.contains("Provider code: INTERNAL"));
784        assert!(status_display.contains("Retry-After: 120s"));
785
786        let format_error = LLMError::ResponseFormatError {
787            message: "bad json".into(),
788            raw_response: "not-json".into(),
789        };
790        assert!(format_error.to_string().contains("Response Format Error"));
791        assert!(format_error.to_string().contains("Raw response"));
792
793        let input_block = LLMError::GuardrailBlocked {
794            phase: GuardrailPhase::Input,
795            guard: "g".into(),
796            rule_id: "r".into(),
797            category: "c".into(),
798            severity: "high".into(),
799            message: "blocked".into(),
800        };
801        assert!(input_block.to_string().contains("guardrail blocked input"));
802
803        let output_block = LLMError::GuardrailBlocked {
804            phase: GuardrailPhase::Output,
805            guard: "g".into(),
806            rule_id: "r".into(),
807            category: "c".into(),
808            severity: "high".into(),
809            message: "blocked".into(),
810        };
811        assert!(
812            output_block
813                .to_string()
814                .contains("guardrail blocked output")
815        );
816
817        let execution_failed = LLMError::GuardrailExecutionFailed {
818            guard: "g".into(),
819            message: "runtime".into(),
820        };
821        assert!(
822            execution_failed
823                .to_string()
824                .contains("guardrail execution failed")
825        );
826    }
827
828    #[cfg(not(target_arch = "wasm32"))]
829    #[tokio::test]
830    async fn test_from_reqwest_connection_error() {
831        let client = reqwest::Client::new();
832        let err = client
833            .get("http://127.0.0.1:1")
834            .send()
835            .await
836            .expect_err("connection should fail");
837
838        let llm_err = LLMError::from(err);
839        match llm_err {
840            LLMError::HttpError(message) => {
841                assert!(message.starts_with("connection failed:"));
842            }
843            other => panic!("unexpected error: {other:?}"),
844        }
845    }
846}