Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/warehouse/generator/ollama.rs

//! `ollama` generator (`gen-ollama`) — the prior-art ollama HTTP client adapted
//! to the [`Generator`] trait. **NOT one of the three real backends** (candle /
//! mistralrs / onnx); it's an OFF-by-default convenience so an existing ollama
//! bake-off can be driven through the factory (`--gen ollama:<tag>`). It pulls
//! no new dependency — it reuses the already-vendored `ureq`.
//!
//! This wraps the same `{host}/api/generate` call the original
//! [`OllamaCaller`](crate::warehouse::agent_model_runs::OllamaCaller) makes, so
//! the HTTP behavior is unchanged; only the surface (a `Generator` rather than a
//! bare `ModelCaller`) is new.

use anyhow::{Result, anyhow};

use super::{Backend, GenAnswer, GenRequest, Generator};

/// Ollama-over-HTTP generator. Targets a specific model tag at a host.
pub struct OllamaGenerator {
    id: String,
    model: String,
    host: String,
}

impl OllamaGenerator {
    /// New generator for `model` (an ollama tag) at `host` (default
    /// `$OLLAMA_HOST`, else `http://localhost:11434`).
    pub fn new(model: &str, host: Option<String>) -> Self {
        let host = host
            .or_else(|| std::env::var("OLLAMA_HOST").ok())
            .unwrap_or_else(|| "http://localhost:11434".to_string());
        let model = if model.is_empty() {
            "llama3".to_string()
        } else {
            model.to_string()
        };
        Self {
            id: format!("ollama:{model}"),
            model,
            host: host.trim_end_matches('/').to_string(),
        }
    }
}

impl Backend for OllamaGenerator {
    fn id(&self) -> &str {
        &self.id
    }

    /// Available when the ollama daemon answers its `/api/tags` endpoint.
    fn available(&self) -> bool {
        let url = format!("{}/api/tags", self.host);
        crate::http_agent().get(&url)
            .config().timeout_global(Some(std::time::Duration::from_millis(500))).build()
            .call()
            .is_ok()
    }
}

impl Generator for OllamaGenerator {
    fn complete(&self, req: &GenRequest) -> Result<GenAnswer> {
        let url = format!("{}/api/generate", self.host);
        let mut body = serde_json::json!({
            "model": self.model,
            "prompt": req.prompt,
            "stream": false,
            "options": {
                "temperature": req.temperature,
                "num_predict": req.max_tokens,
            },
        });
        if let Some(sys) = &req.system {
            body["system"] = serde_json::Value::String(sys.clone());
        }
        if !req.stop.is_empty() {
            body["options"]["stop"] = serde_json::json!(req.stop);
        }
        let body_str = serde_json::to_string(&body)?;
        let started = std::time::Instant::now();
        let resp = crate::http_agent().post(&url)
            .header("Content-Type", "application/json")
            .send(&body_str)
            .map_err(|e| anyhow!("ollama POST {url} failed: {e}"))?;
        let txt = resp
            .into_body()
            .read_to_string()
            .map_err(|e| anyhow!("ollama response not readable: {e}"))?;
        let v: serde_json::Value =
            serde_json::from_str(&txt).map_err(|e| anyhow!("ollama response not JSON: {e}"))?;
        let latency_ms = started.elapsed().as_secs_f64() * 1000.0;

        let text = v
            .get("response")
            .and_then(|x| x.as_str())
            .unwrap_or("")
            .to_string();
        let tokens_in = v
            .get("prompt_eval_count")
            .and_then(|x| x.as_i64())
            .unwrap_or(0);
        let tokens_out = v.get("eval_count").and_then(|x| x.as_i64()).unwrap_or(0);
        let tokens_per_s = match v.get("eval_duration").and_then(|x| x.as_i64()) {
            Some(ns) if ns > 0 => tokens_out as f64 / (ns as f64 / 1e9),
            _ if latency_ms > 0.0 => tokens_out as f64 / (latency_ms / 1000.0),
            _ => 0.0,
        };
        Ok(GenAnswer {
            text,
            tokens_in,
            tokens_out,
            tokens_per_s,
            latency_ms,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn id_and_default_host() {
        let r#gen = OllamaGenerator::new("qwen2", Some("http://example:11434/".into()));
        assert_eq!(r#gen.id(), "ollama:qwen2");
        assert_eq!(r#gen.host, "http://example:11434", "trailing slash trimmed");
    }

    #[test]
    fn empty_model_defaults() {
        let r#gen = OllamaGenerator::new("", Some("http://h:1".into()));
        assert_eq!(r#gen.id(), "ollama:llama3");
    }

    #[test]
    fn available_is_false_against_a_dead_host() {
        // A host that won't answer ⇒ available() is false (no panic, real probe).
        let r#gen = OllamaGenerator::new("m", Some("http://127.0.0.1:1".into()));
        assert!(!r#gen.available());
    }
}