use super::*;
#[derive(Debug, Serialize)]
struct OllamaRequest<'a> {
model: &'a str,
prompt: &'a str,
images: Vec<String>,
stream: bool,
}
#[derive(Debug, Deserialize)]
struct OllamaResponse {
response: String,
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn vlm_query(
client: &reqwest::Client,
provider: VlmProvider,
endpoint: &str,
model: &str,
api_key: Option<&str>,
image_b64: &str,
prompt: &str,
timeout_ms: u64,
) -> Result<String> {
match provider {
VlmProvider::Ollama => {
vlm_query_ollama(client, endpoint, model, image_b64, prompt, timeout_ms).await
}
VlmProvider::Anthropic => {
let key = api_key.ok_or_else(|| anyhow!("anthropic provider needs api_key"))?;
vlm_query_anthropic(client, endpoint, model, key, image_b64, prompt, timeout_ms).await
}
VlmProvider::OpenAI => {
let key = api_key.ok_or_else(|| anyhow!("openai provider needs api_key"))?;
vlm_query_openai(
client, endpoint, model, key, image_b64, prompt, timeout_ms, false,
)
.await
}
VlmProvider::LlamaCpp => {
vlm_query_openai(
client,
endpoint,
model,
api_key.unwrap_or(""),
image_b64,
prompt,
timeout_ms,
true,
)
.await
}
}
}
async fn vlm_query_ollama(
client: &reqwest::Client,
endpoint: &str,
model: &str,
image_b64: &str,
prompt: &str,
timeout_ms: u64,
) -> Result<String> {
let req = OllamaRequest {
model,
prompt,
images: vec![image_b64.to_string()],
stream: false,
};
let resp = client
.post(format!("{}/api/generate", endpoint.trim_end_matches('/')))
.json(&req)
.timeout(Duration::from_millis(timeout_ms))
.send()
.await
.map_err(|e| anyhow!("ollama request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("ollama returned {}: {}", status, body));
}
let ollama_resp: OllamaResponse = resp
.json()
.await
.map_err(|e| anyhow!("ollama json parse: {}", e))?;
Ok(ollama_resp.response)
}
async fn vlm_query_anthropic(
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: &str,
image_b64: &str,
prompt: &str,
timeout_ms: u64,
) -> Result<String> {
let body = serde_json::json!({
"model": model,
"max_tokens": 512,
"messages": [{
"role": "user",
"content": [
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": image_b64 } },
{ "type": "text", "text": prompt },
],
}],
});
let resp = client
.post(format!("{}/v1/messages", endpoint.trim_end_matches('/')))
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.timeout(Duration::from_millis(timeout_ms))
.send()
.await
.map_err(|e| anyhow!("anthropic request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("anthropic returned {}: {}", status, body));
}
let val: serde_json::Value = resp
.json()
.await
.map_err(|e| anyhow!("anthropic json parse: {}", e))?;
val["content"]
.as_array()
.and_then(|arr| arr.iter().find(|c| c["type"] == "text"))
.and_then(|c| c["text"].as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("anthropic response missing text content: {val}"))
}
pub(crate) fn openai_request_body(
model: &str,
image_b64: &str,
prompt: &str,
disable_thinking: bool,
) -> serde_json::Value {
let data_url = format!("data:image/png;base64,{}", image_b64);
let mut body = serde_json::json!({
"model": model,
"max_tokens": 512,
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": prompt },
{ "type": "image_url", "image_url": { "url": data_url } },
],
}],
});
if disable_thinking {
body["chat_template_kwargs"] = serde_json::json!({ "enable_thinking": false });
}
body
}
#[allow(clippy::too_many_arguments)]
async fn vlm_query_openai(
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: &str,
image_b64: &str,
prompt: &str,
timeout_ms: u64,
disable_thinking: bool,
) -> Result<String> {
let body = openai_request_body(model, image_b64, prompt, disable_thinking);
let resp = client
.post(format!(
"{}/v1/chat/completions",
endpoint.trim_end_matches('/')
))
.header("authorization", format!("Bearer {}", api_key))
.header("content-type", "application/json")
.json(&body)
.timeout(Duration::from_millis(timeout_ms))
.send()
.await
.map_err(|e| anyhow!("openai request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("openai returned {}: {}", status, body));
}
let val: serde_json::Value = resp
.json()
.await
.map_err(|e| anyhow!("openai json parse: {}", e))?;
val["choices"][0]["message"]["content"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("openai response missing content: {val}"))
}
pub(crate) fn extract_json(text: &str) -> Option<&str> {
if let Some(start) = text.find("```json") {
let rest = &text[start + 7..];
if let Some(end) = rest.find("```") {
return Some(rest[..end].trim());
}
}
if let Some(start) = text.find('{') {
let rest = &text[start..];
let mut depth = 0;
let mut end = None;
for (i, c) in rest.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = Some(i + 1);
break;
}
}
_ => {}
}
}
if let Some(e) = end {
return Some(&rest[..e]);
}
}
None
}