Skip to main content

lash_core/llm/
transport.rs

1//! Transport-level failure types and attachment capability diagnostics shared
2//! by provider adapters.
3
4pub use lash_http_transport::retry_after_from_headers;
5pub use lash_sansio::llm::types::ProviderFailureKind;
6pub type ProviderFailure = lash_http_transport::HttpTransportError;
7pub type LlmTransportError = lash_http_transport::HttpTransportError;
8
9use lash_sansio::llm::types::AttachmentSource;
10
11pub const OPENAI_IMAGE_MIMES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
12// OpenAI's current `input_file` table explicitly lists every MIME below:
13// https://developers.openai.com/api/docs/guides/file-inputs#full-list-of-accepted-file-types
14pub const OPENAI_FILE_MIMES: &[&str] = &[
15    "application/pdf",
16    "application/json",
17    "application/msword",
18    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
19    "application/vnd.ms-excel",
20    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
21    "application/vnd.ms-powerpoint",
22    "application/vnd.openxmlformats-officedocument.presentationml.presentation",
23    "text/csv",
24    "text/html",
25    "text/markdown",
26    "text/plain",
27];
28
29pub const ANTHROPIC_IMAGE_MIMES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
30pub const ANTHROPIC_FILE_MIMES: &[&str] = &["application/pdf"];
31
32pub const GOOGLE_IMAGE_MIMES: &[&str] = &[
33    "image/jpeg",
34    "image/png",
35    "image/webp",
36    "image/heic",
37    "image/heif",
38];
39pub const GOOGLE_FILE_MIMES: &[&str] = &["application/pdf"];
40pub const GOOGLE_MEDIA_FAMILIES: &[&str] = &["audio", "text", "video"];
41
42/// Derive the cross-provider attachment diagnostic from adapter capability
43/// lists instead of maintaining a second MIME table at each call site.
44pub fn known_attachment_acceptors(source: &AttachmentSource) -> Vec<&'static str> {
45    if let AttachmentSource::ProviderFile { provider_scope, .. } = source {
46        return match provider_scope.provider.to_ascii_lowercase().as_str() {
47            "openai" => vec!["OpenAI Responses"],
48            "anthropic" => vec!["Anthropic Messages"],
49            "google" | "google_oauth" | "gemini" => vec!["Google Gemini"],
50            _ => Vec::new(),
51        };
52    }
53
54    let media_type = source.media_type().expect("MIME-bearing source");
55    let mime = media_type.as_str();
56    let borrowed_url = matches!(source, AttachmentSource::ExternalUrl { .. });
57    let mut providers = Vec::new();
58    if OPENAI_IMAGE_MIMES.contains(&mime) || OPENAI_FILE_MIMES.contains(&mime) {
59        providers.push("OpenAI Responses");
60    }
61    if OPENAI_IMAGE_MIMES.contains(&mime) {
62        providers.push("OpenAI Chat Completions");
63    }
64    if ANTHROPIC_IMAGE_MIMES.contains(&mime) || ANTHROPIC_FILE_MIMES.contains(&mime) {
65        providers.push("Anthropic Messages");
66    }
67    if !borrowed_url
68        && (GOOGLE_IMAGE_MIMES.contains(&mime)
69            || GOOGLE_MEDIA_FAMILIES.contains(&media_type.family())
70            || GOOGLE_FILE_MIMES.contains(&mime))
71    {
72        providers.push("Google Gemini");
73    }
74    providers
75}
76
77pub fn unsupported_attachment_capability(
78    provider: &str,
79    source: &AttachmentSource,
80    accepted_by: &[&str],
81) -> LlmTransportError {
82    let accepted = if accepted_by.is_empty() {
83        "none".to_string()
84    } else {
85        accepted_by.join(", ")
86    };
87    let message = match source {
88        AttachmentSource::ProviderFile {
89            provider_scope,
90            media_type,
91            ..
92        } => match media_type {
93            Some(media_type) => format!(
94                "{provider} cannot materialize attachment MIME `{media_type}` from source `provider_file` scoped to provider `{}`; providers accepting this source: {accepted}",
95                provider_scope.provider
96            ),
97            None => format!(
98                "{provider} cannot materialize attachment source `provider_file` scoped to provider `{}`; the source carries no caller MIME; providers accepting this source: {accepted}",
99                provider_scope.provider
100            ),
101        },
102        source => {
103            let media_type = source
104                .media_type()
105                .expect("non-provider-file attachment sources carry a MIME");
106            format!(
107                "{provider} cannot materialize attachment MIME `{media_type}` from source `{}`; providers accepting this MIME/source: {accepted}",
108                source_kind(source)
109            )
110        }
111    };
112    ProviderFailure::new(message)
113        .with_kind(ProviderFailureKind::Validation)
114        .with_code("unsupported_attachment_capability")
115}
116
117pub fn source_kind(source: &AttachmentSource) -> &'static str {
118    match source {
119        AttachmentSource::Inline { .. } => "inline",
120        AttachmentSource::Stored { .. } => "stored",
121        AttachmentSource::ExternalUrl { .. } => "external_url",
122        AttachmentSource::ProviderFile { .. } => "provider_file",
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use lash_sansio::MediaType;
130    use lash_sansio::llm::types::ProviderFileScope;
131
132    fn inline(mime: &str) -> AttachmentSource {
133        AttachmentSource::inline(MediaType::parse(mime).unwrap(), vec![1])
134    }
135
136    #[test]
137    fn attachment_acceptors_are_derived_from_provider_capabilities() {
138        assert_eq!(
139            known_attachment_acceptors(&inline("image/gif")),
140            vec![
141                "OpenAI Responses",
142                "OpenAI Chat Completions",
143                "Anthropic Messages"
144            ]
145        );
146        assert_eq!(
147            known_attachment_acceptors(&inline("application/pdf")),
148            vec!["OpenAI Responses", "Anthropic Messages", "Google Gemini"]
149        );
150        assert_eq!(
151            known_attachment_acceptors(&inline("audio/mpeg")),
152            vec!["Google Gemini"]
153        );
154    }
155
156    #[test]
157    fn openai_file_capabilities_match_the_current_input_file_documentation() {
158        assert_eq!(
159            OPENAI_FILE_MIMES,
160            [
161                "application/pdf",
162                "application/json",
163                "application/msword",
164                "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
165                "application/vnd.ms-excel",
166                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
167                "application/vnd.ms-powerpoint",
168                "application/vnd.openxmlformats-officedocument.presentationml.presentation",
169                "text/csv",
170                "text/html",
171                "text/markdown",
172                "text/plain",
173            ]
174        );
175    }
176
177    #[test]
178    fn attachment_acceptors_account_for_source_restrictions_and_file_scope() {
179        let borrowed_text =
180            AttachmentSource::external_url(MediaType::parse("text/plain").unwrap(), "https://x");
181        assert_eq!(
182            known_attachment_acceptors(&borrowed_text),
183            vec!["OpenAI Responses"]
184        );
185
186        let google_file = AttachmentSource::provider_file(
187            ProviderFileScope::new("gemini", "credential"),
188            "file-id",
189            None,
190        );
191        assert_eq!(
192            known_attachment_acceptors(&google_file),
193            vec!["Google Gemini"]
194        );
195    }
196}