Skip to main content

lash_core/llm/
transport.rs

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