Skip to main content

fotos_lib/ai/
openai_compat.rs

1/// Generic OpenAI-compatible vision analyzer.
2///
3/// Supports any server implementing `/v1/chat/completions` — OpenAI, llama-server,
4/// LM Studio, Groq, Together AI, Ollama v2+, and any other compatible service.
5use anyhow::{bail, Result};
6use std::time::{Duration, Instant};
7
8use super::llm::LlmOutput;
9
10const TIMEOUT_SECS: u64 = 30;
11
12/// Analyze an image using an OpenAI-compatible `/chat/completions` endpoint.
13///
14/// `base_url` should include the path prefix (e.g. `https://api.openai.com/v1`
15/// or `http://localhost:11434/v1`). The function appends `/chat/completions`.
16///
17/// `api_key` may be empty for local servers that require no authentication.
18pub 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}