ai_lib/provider/
utils.rs

1use crate::types::common::Content;
2use base64::engine::general_purpose::STANDARD as base64_engine;
3use base64::Engine as _;
4use serde_json::json;
5use std::path::Path;
6
7/// Convert Content into a JSON value suitable for generic providers.
8/// - If Content::Text -> string
9/// - If Content::Json -> object
10/// - If Content::Image with url -> {"image": {"url": ...}}
11/// - If Content::Image without url -> inline base64 data
12/// - If Content::Audio -> similar strategy
13pub fn content_to_provider_value(content: &Content) -> serde_json::Value {
14    match content {
15        Content::Text(s) => serde_json::Value::String(s.clone()),
16        Content::Json(v) => v.clone(),
17        Content::Image {
18            url,
19            mime: _mime,
20            name,
21        } => {
22            if let Some(u) = url {
23                json!({"image": {"url": u}})
24            } else {
25                // No URL: attempt to treat name as path and inline base64 if readable
26                if let Some(n) = name {
27                    if let Some(data_url) = upload_file_inline(n, _mime.as_deref()) {
28                        json!({"image": {"data": data_url}})
29                    } else {
30                        json!({"image": {"note": "no url, name not a readable path"}})
31                    }
32                } else {
33                    json!({"image": {"note": "no url"}})
34                }
35            }
36        }
37        Content::Audio { url, mime: _mime } => {
38            if let Some(u) = url {
39                json!({"audio": {"url": u}})
40            } else {
41                json!({"audio": {"note": "no url"}})
42            }
43        }
44    }
45}
46use crate::types::AiLibError;
47use reqwest::Client;
48use std::fs;
49use std::time::Duration;
50
51/// Upload or inline a local file path; current default behavior is to inline as data URL
52/// Returns a data URL string if successful, or None on failure.
53pub fn upload_file_inline(path: &str, mime: Option<&str>) -> Option<String> {
54    let p = Path::new(path);
55    if !p.exists() {
56        return None;
57    }
58    if let Ok(bytes) = fs::read(p) {
59        let b64 = base64_engine.encode(bytes);
60        let mime = mime.unwrap_or("application/octet-stream");
61        Some(format!("data:{};base64,{}", mime, b64))
62    } else {
63        None
64    }
65}
66
67/// Upload a local file to the provider's upload endpoint using multipart/form-data.
68/// This function is conservative: it only attempts an upload when an `upload_url` is provided.
69/// Returns the provider-hosted file URL on success.
70pub async fn upload_file_to_provider(
71    upload_url: &str,
72    path: &str,
73    field_name: &str,
74) -> Result<String, crate::types::AiLibError> {
75    use reqwest::multipart;
76
77    let p = Path::new(path);
78    if !p.exists() {
79        return Err(crate::types::AiLibError::ProviderError(
80            "file not found".to_string(),
81        ));
82    }
83
84    let file_name = p.file_name().and_then(|n| n.to_str()).unwrap_or("file.bin");
85    let bytes = fs::read(p)
86        .map_err(|e| crate::types::AiLibError::ProviderError(format!("read error: {}", e)))?;
87
88    let part = multipart::Part::bytes(bytes).file_name(file_name.to_string());
89    let form = multipart::Form::new().part(field_name.to_string(), part);
90
91    // Build client: respect AI_PROXY_URL if provided, otherwise disable proxy so
92    // local mock servers (127.0.0.1) work even when system HTTP_PROXY is set.
93    let client_builder = reqwest::Client::builder();
94    let client_builder = if let Ok(proxy_url) = std::env::var("AI_PROXY_URL") {
95        if let Ok(proxy) = reqwest::Proxy::all(&proxy_url) {
96            client_builder.proxy(proxy)
97        } else {
98            client_builder.no_proxy()
99        }
100    } else {
101        client_builder.no_proxy()
102    };
103
104    let client = client_builder.build().map_err(|e| {
105        crate::types::AiLibError::NetworkError(format!("failed to build http client: {}", e))
106    })?;
107    let resp = client
108        .post(upload_url)
109        .multipart(form)
110        .send()
111        .await
112        .map_err(|e| crate::types::AiLibError::NetworkError(format!("upload failed: {}", e)))?;
113    let status = resp.status();
114    if !status.is_success() {
115        let txt = resp.text().await.unwrap_or_default();
116        return Err(crate::types::AiLibError::ProviderError(format!(
117            "upload returned {}: {}",
118            status, txt
119        )));
120    }
121
122    // Naive: expect JSON response containing a 'url' field
123    let j: serde_json::Value = resp
124        .json()
125        .await
126        .map_err(|e| crate::types::AiLibError::ProviderError(format!("parse response: {}", e)))?;
127    parse_upload_response(j)
128}
129
130/// Upload a file using an injected transport if available.
131/// If `transport` is Some, it will be used to post a multipart form. Otherwise,
132/// falls back to `upload_file_to_provider` which constructs its own client.
133pub async fn upload_file_with_transport(
134    transport: Option<crate::transport::dyn_transport::DynHttpTransportRef>,
135    upload_url: &str,
136    path: &str,
137    field_name: &str,
138) -> Result<String, crate::types::AiLibError> {
139    use std::fs;
140    use std::path::Path;
141
142    let p = Path::new(path);
143    if !p.exists() {
144        return Err(crate::types::AiLibError::ProviderError(
145            "file not found".to_string(),
146        ));
147    }
148    let file_name = p
149        .file_name()
150        .and_then(|n| n.to_str())
151        .unwrap_or("file.bin")
152        .to_string();
153    let bytes = fs::read(p)
154        .map_err(|e| crate::types::AiLibError::ProviderError(format!("read error: {}", e)))?;
155
156    if let Some(t) = transport {
157        // Use the injected transport to perform multipart upload
158        let headers = None;
159        let j = t
160            .upload_multipart(upload_url, headers, field_name, &file_name, bytes)
161            .await?;
162        return parse_upload_response(j);
163    }
164
165    // Fallback to existing implementation
166    upload_file_to_provider(upload_url, path, field_name).await
167}
168
169/// Parse a provider upload JSON response into a returned string.
170/// Accepts either `{ "url": "..." }` or `{ "id": "..." }` and returns the url or id.
171pub(crate) fn parse_upload_response(
172    j: serde_json::Value,
173) -> Result<String, crate::types::AiLibError> {
174    if let Some(url) = j.get("url").and_then(|v| v.as_str()) {
175        return Ok(url.to_string());
176    }
177    if let Some(id) = j.get("id").and_then(|v| v.as_str()) {
178        return Ok(id.to_string());
179    }
180    Err(crate::types::AiLibError::ProviderError(
181        "upload response missing url/id".to_string(),
182    ))
183}
184
185/// Simple health check helper for provider base URLs.
186/// Tries to GET {base_url}/models (common OpenAI-compatible endpoint) or the base URL
187/// and returns Ok(()) if reachable and returns an AiLibError otherwise.
188pub async fn health_check(base_url: &str) -> Result<(), AiLibError> {
189    let client_builder = Client::builder().timeout(Duration::from_secs(5));
190
191    // honor AI_PROXY_URL if set
192    let client_builder = if let Ok(proxy_url) = std::env::var("AI_PROXY_URL") {
193        if let Ok(proxy) = reqwest::Proxy::all(&proxy_url) {
194            client_builder.proxy(proxy)
195        } else {
196            client_builder
197        }
198    } else {
199        client_builder
200    };
201
202    let client = client_builder
203        .build()
204        .map_err(|e| AiLibError::NetworkError(format!("Failed to build HTTP client: {}", e)))?;
205
206    // Try models endpoint first
207    let models_url = if base_url.ends_with('/') {
208        format!("{}models", base_url)
209    } else {
210        format!("{}/models", base_url)
211    };
212
213    let resp = client.get(&models_url).send().await;
214    match resp {
215        Ok(r) if r.status().is_success() => Ok(()),
216        _ => {
217            // fallback to base url
218            let resp2 = client.get(base_url).send().await;
219            match resp2 {
220                Ok(r2) if r2.status().is_success() => Ok(()),
221                Ok(r2) => Err(AiLibError::NetworkError(format!(
222                    "Health check returned status {}",
223                    r2.status()
224                ))),
225                Err(e) => Err(AiLibError::NetworkError(format!(
226                    "Health check request failed: {}",
227                    e
228                ))),
229            }
230        }
231    }
232}
233
234// Tests moved to file end to satisfy clippy::items-after-test-module
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use serde_json::json;
240
241    #[test]
242    fn parse_upload_response_url() {
243        let j = json!({"url": "https://cdn.example.com/file.png"});
244        let res = parse_upload_response(j).unwrap();
245        assert_eq!(res, "https://cdn.example.com/file.png");
246    }
247
248    #[test]
249    fn parse_upload_response_id() {
250        let j = json!({"id": "file_123"});
251        let res = parse_upload_response(j).unwrap();
252        assert_eq!(res, "file_123");
253    }
254}