use crate::infer::{InferBackend, InferRequest, InferResult};
use async_trait::async_trait;
use futures_util::StreamExt;
use serde::Deserialize;
pub const DEFAULT_HOST: &str = "http://localhost:11434";
#[derive(Debug, Deserialize)]
struct Chunk {
#[serde(default)]
response: String,
#[serde(default)]
done: bool,
#[serde(default)]
prompt_eval_count: Option<u32>,
#[serde(default)]
eval_count: Option<u32>,
#[serde(default)]
error: Option<String>,
}
pub struct OllamaBackend {
client: reqwest::Client,
host: String,
model: String,
}
impl OllamaBackend {
pub fn new(host: impl Into<String>, model: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
host: host.into().trim_end_matches('/').to_string(),
model: model.into(),
}
}
pub fn host_from_env() -> String {
std::env::var("OLLAMA_HOST").unwrap_or_else(|_| DEFAULT_HOST.to_string())
}
}
#[async_trait]
impl InferBackend for OllamaBackend {
async fn infer(
&self,
req: InferRequest<'_>,
on_token: &mut (dyn for<'t> FnMut(&'t str) -> bool + Send),
) -> anyhow::Result<InferResult> {
let images: Vec<String> = req
.images
.iter()
.map(|bytes| {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
})
.collect();
let response = self
.client
.post(format!("{}/api/generate", self.host))
.json(&serde_json::json!({
"model": self.model,
"prompt": req.prompt,
"stream": true,
"images": images,
"options": { "num_predict": req.max_tokens },
}))
.send()
.await
.map_err(|e| {
anyhow::anyhow!(
"could not reach Ollama at {} ({e}). Is it running? \
Set OLLAMA_HOST to point elsewhere.",
self.host
)
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama returned {status}: {body}");
}
let mut text = String::new();
let mut tokens_in = 0;
let mut tokens_out = 0;
let mut stream = response.bytes_stream();
let mut buf = String::new();
'outer: while let Some(chunk) = stream.next().await {
buf.push_str(&String::from_utf8_lossy(&chunk?));
while let Some(newline) = buf.find('\n') {
let line: String = buf.drain(..=newline).collect();
let line = line.trim();
if line.is_empty() {
continue;
}
let chunk: Chunk = serde_json::from_str(line).map_err(|e| {
anyhow::anyhow!("malformed response from Ollama: {e} in {line}")
})?;
if let Some(error) = chunk.error {
anyhow::bail!("Ollama error: {error}");
}
if !chunk.response.is_empty() {
text.push_str(&chunk.response);
tokens_out += 1;
if !on_token(&chunk.response) {
break 'outer;
}
}
if chunk.done {
tokens_in = chunk.prompt_eval_count.unwrap_or(0);
tokens_out = chunk.eval_count.unwrap_or(tokens_out);
break 'outer;
}
}
}
Ok(InferResult {
text,
tokens_in,
tokens_out,
})
}
fn model_name(&self) -> String {
self.model.clone()
}
fn supports_images(&self) -> bool {
true
}
async fn embed(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let response = self
.client
.post(format!("{}/api/embed", self.host))
.json(&serde_json::json!({ "model": self.model, "input": texts }))
.send()
.await
.map_err(|e| anyhow::anyhow!("embedding via {}: {e}", self.host))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| anyhow::anyhow!("reading the embedding response: {e}"))?;
if !status.is_success() {
anyhow::bail!("embedding with `{}`: {status}: {body}", self.model);
}
let parsed: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| anyhow::anyhow!("embedding response was not JSON: {e}: {body}"))?;
let rows = parsed
.get("embeddings")
.and_then(|v| v.as_array())
.ok_or_else(|| {
anyhow::anyhow!("embedding response had no `embeddings` array: {body}")
})?;
if rows.len() != texts.len() {
anyhow::bail!(
"asked `{}` for {} embeddings and got {} — refusing to pair vectors with \
texts by guesswork",
self.model,
texts.len(),
rows.len()
);
}
let mut out = Vec::with_capacity(rows.len());
for (i, row) in rows.iter().enumerate() {
let vector: Vec<f32> = row
.as_array()
.ok_or_else(|| anyhow::anyhow!("embedding {i} was not an array: {row}"))?
.iter()
.map(|v| v.as_f64().unwrap_or(f64::NAN) as f32)
.collect();
if vector.is_empty() || vector.iter().any(|v| v.is_nan()) {
anyhow::bail!(
"embedding {i} from `{}` is empty or contains non-numbers; storing it \
would poison every similarity computed against it",
self.model
);
}
out.push(vector);
}
Ok(out)
}
fn supports_embeddings(&self) -> bool {
true
}
}
pub struct OllamaFactory;
impl crate::backend::BackendFactory for OllamaFactory {
fn provider(&self) -> &'static str {
"ollama"
}
fn describe(&self) -> &'static str {
"a local Ollama instance; target is a model tag such as `llama3.2:1b`"
}
fn build(&self, target: &str) -> anyhow::Result<std::sync::Arc<dyn InferBackend>> {
if target.is_empty() {
anyhow::bail!("an Ollama model name is required, e.g. `llama3.2:1b`");
}
Ok(std::sync::Arc::new(OllamaBackend::new(
OllamaBackend::host_from_env(),
target,
)))
}
}