lash_core/llm/
transport.rs1#[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#[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}