Skip to main content

lash_core/llm/
transport.rs

1//! Transport-level failure type shared by provider transport components.
2
3/// Canonical provider-failure classification. Defined in
4/// `lash-sansio::llm::types` (the session-model `ErrorEnvelope` carries it in
5/// durable snapshots) and re-exported here as the transport-facing home.
6pub use lash_sansio::llm::types::ProviderFailureKind;
7
8#[derive(Debug, thiserror::Error, Clone)]
9#[error("{message}")]
10pub struct ProviderFailure {
11    pub kind: ProviderFailureKind,
12    pub message: String,
13    pub retryable: bool,
14    pub status: Option<u16>,
15    pub raw: Option<String>,
16    pub code: Option<String>,
17    pub terminal_reason: lash_sansio::llm::types::LlmTerminalReason,
18    pub headers: Vec<(String, String)>,
19    pub retry_after: Option<std::time::Duration>,
20    pub request_body: Option<String>,
21}
22
23impl ProviderFailure {
24    pub fn new(message: impl Into<String>) -> Self {
25        Self {
26            kind: ProviderFailureKind::Unknown,
27            message: message.into(),
28            retryable: false,
29            status: None,
30            raw: None,
31            code: None,
32            terminal_reason: lash_sansio::llm::types::LlmTerminalReason::ProviderError,
33            headers: Vec::new(),
34            retry_after: None,
35            request_body: None,
36        }
37    }
38
39    pub fn with_kind(mut self, kind: ProviderFailureKind) -> Self {
40        self.kind = kind;
41        self
42    }
43
44    pub fn retryable(mut self, retryable: bool) -> Self {
45        self.retryable = retryable;
46        self
47    }
48
49    pub fn with_status(mut self, status: u16) -> Self {
50        self.status = Some(status);
51        if self.code.is_none() {
52            self.code = Some(status.to_string());
53        }
54        self
55    }
56
57    pub fn with_raw(mut self, raw: impl Into<String>) -> Self {
58        self.raw = Some(raw.into());
59        self
60    }
61
62    pub fn with_code(mut self, code: impl Into<String>) -> Self {
63        self.code = Some(code.into());
64        self
65    }
66
67    pub fn with_terminal_reason(
68        mut self,
69        reason: lash_sansio::llm::types::LlmTerminalReason,
70    ) -> Self {
71        self.terminal_reason = reason;
72        self
73    }
74
75    pub fn with_headers<I, K, V>(mut self, headers: I) -> Self
76    where
77        I: IntoIterator<Item = (K, V)>,
78        K: Into<String>,
79        V: Into<String>,
80    {
81        self.headers = headers
82            .into_iter()
83            .map(|(name, value)| (name.into(), value.into()))
84            .collect();
85        self.retry_after = retry_after_from_headers(&self.headers);
86        self
87    }
88
89    pub fn with_retry_after(mut self, retry_after: std::time::Duration) -> Self {
90        self.retry_after = Some(retry_after);
91        self
92    }
93
94    pub fn with_request_body(mut self, request_body: impl Into<String>) -> Self {
95        self.request_body = Some(request_body.into());
96        self
97    }
98}
99
100pub fn retry_after_from_headers(headers: &[(String, String)]) -> Option<std::time::Duration> {
101    let value = headers
102        .iter()
103        .find(|(name, _)| name.eq_ignore_ascii_case("retry-after"))?
104        .1
105        .trim();
106    if let Ok(seconds) = value.parse::<u64>() {
107        return Some(std::time::Duration::from_secs(seconds));
108    }
109    None
110}
111
112pub type LlmTransportError = ProviderFailure;
113
114/// Validate that every image attachment in `req` carries a MIME type accepted
115/// by the provider. Returns a `Validation`-kind `LlmTransportError` with code
116/// `unsupported_image_format` and a descriptive message on the first
117/// unsupported attachment, naming the provider and the offending MIME.
118///
119/// Provider adapters call this at the top of their request-building pipeline
120/// to fail fast with a clear runtime-side error rather than relying on the
121/// upstream API to reject the request with a less actionable message.
122#[expect(
123    clippy::result_large_err,
124    reason = "provider transport errors are a public typed API and carry request/response diagnostics"
125)]
126pub fn validate_image_attachments(
127    req: &lash_sansio::llm::types::LlmRequest,
128    accepted_mimes: &[&str],
129    provider_name: &str,
130) -> Result<(), LlmTransportError> {
131    for (idx, att) in req.attachments.iter().enumerate() {
132        let mime = att.mime.trim().to_ascii_lowercase();
133        let normalized = if mime == "image/jpg" {
134            "image/jpeg"
135        } else {
136            mime.as_str()
137        };
138        if !accepted_mimes.contains(&normalized) {
139            return Err(ProviderFailure::new(format!(
140                "{provider_name} does not accept image attachments of type `{}` (attachment index {idx}); accepted: {}",
141                att.mime,
142                accepted_mimes.join(", "),
143            ))
144            .with_kind(ProviderFailureKind::Validation)
145            .with_code("unsupported_image_format"));
146        }
147    }
148    Ok(())
149}