fotos_lib/ai/
openai_compat.rs1use anyhow::{bail, Result};
6use std::time::{Duration, Instant};
7
8use super::llm::LlmOutput;
9
10const TIMEOUT_SECS: u64 = 30;
11
12pub async fn analyze(
19 image_b64: &str,
20 prompt: &str,
21 base_url: &str,
22 model: &str,
23 api_key: &str,
24) -> Result<LlmOutput> {
25 let client = reqwest::Client::builder()
26 .timeout(Duration::from_secs(TIMEOUT_SECS))
27 .build()?;
28
29 let base = base_url.trim_end_matches('/');
30 let url = format!("{base}/chat/completions");
31
32 let data_url = format!("data:image/jpeg;base64,{image_b64}");
33 let body = serde_json::json!({
34 "model": model,
35 "max_tokens": 1024,
36 "messages": [{
37 "role": "user",
38 "content": [
39 {
40 "type": "image_url",
41 "image_url": { "url": data_url }
42 },
43 {
44 "type": "text",
45 "text": prompt
46 }
47 ]
48 }]
49 });
50
51 let mut req = client.post(&url).json(&body);
52 if !api_key.is_empty() {
53 req = req.bearer_auth(api_key);
54 }
55
56 let start = Instant::now();
57 let resp = req
58 .send()
59 .await
60 .map_err(|e| anyhow::anyhow!("Request to {url} failed: {e}"))?;
61
62 let status = resp.status();
63 let json: serde_json::Value = resp.json().await?;
64
65 if !status.is_success() {
66 let msg = json["error"]["message"].as_str().unwrap_or("unknown error");
67 bail!("API error {status}: {msg}");
68 }
69
70 let response = json["choices"]
71 .as_array()
72 .and_then(|a| a.first())
73 .and_then(|c| c["message"]["content"].as_str())
74 .unwrap_or("")
75 .to_string();
76
77 let tokens_used = json["usage"]["total_tokens"].as_u64().unwrap_or(0) as u32;
78
79 Ok(LlmOutput {
80 response,
81 model: model.to_string(),
82 tokens_used,
83 latency_ms: start.elapsed().as_millis() as u64,
84 })
85}