lash_core/llm/
transport.rs1pub 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"];
12pub 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
42pub 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}