Skip to main content

cuttlefish_host/
ollama.rs

1//! Inference served by a local [Ollama](https://ollama.com) instance.
2//!
3//! Ollama is the first real backend because it is the shortest path to a real
4//! model: it is already an HTTP server that streams, so this is a client rather
5//! than an embedding of llama.cpp. Embedding llama.cpp directly remains
6//! worthwhile later — it removes a process boundary and gives direct control of
7//! the KV cache — but it is a much larger commitment, and nothing above
8//! [`InferBackend`] can tell the difference.
9//!
10//! # The wire format
11//!
12//! `POST /api/generate` with `stream: true` responds with newline-delimited
13//! JSON — one object per token, then a final object carrying counts:
14//!
15//! ```text
16//! {"model":"llama3.2:1b","response":"Hello","done":false}
17//! {"model":"llama3.2:1b","response":" there","done":false}
18//! {"model":"llama3.2:1b","response":"","done":true,"done_reason":"stop",
19//!  "prompt_eval_count":28,"eval_count":3, ...}
20//! ```
21//!
22//! Token-at-a-time delivery is what makes a guest's early-stop verdict
23//! meaningful: the host can act on it while generation is still running rather
24//! than after the fact.
25
26use crate::infer::{InferBackend, InferRequest, InferResult};
27use async_trait::async_trait;
28use futures_util::StreamExt;
29use serde::Deserialize;
30
31/// Where Ollama listens when nothing says otherwise.
32pub const DEFAULT_HOST: &str = "http://localhost:11434";
33
34/// One line of Ollama's streaming response.
35///
36/// Deliberately partial: Ollama sends more fields than this (timings, and a
37/// `context` array that can run to thousands of integers). Naming only what is
38/// used keeps the deserializer from being coupled to fields the project does
39/// not care about, and avoids materializing that context array on every call.
40#[derive(Debug, Deserialize)]
41struct Chunk {
42    #[serde(default)]
43    response: String,
44    #[serde(default)]
45    done: bool,
46    #[serde(default)]
47    prompt_eval_count: Option<u32>,
48    #[serde(default)]
49    eval_count: Option<u32>,
50    /// Present when Ollama itself reports a problem — an unknown model, say.
51    #[serde(default)]
52    error: Option<String>,
53}
54
55/// Serves inference from a local Ollama instance.
56pub struct OllamaBackend {
57    client: reqwest::Client,
58    host: String,
59    model: String,
60}
61
62impl OllamaBackend {
63    /// Target `model` on the Ollama instance at `host`.
64    ///
65    /// `model` is the name as Ollama knows it, `:tag` included.
66    pub fn new(host: impl Into<String>, model: impl Into<String>) -> Self {
67        Self {
68            client: reqwest::Client::new(),
69            // A trailing slash here would produce `//api/generate`, which Ollama
70            // rejects — easy to introduce via an environment variable.
71            host: host.into().trim_end_matches('/').to_string(),
72            model: model.into(),
73        }
74    }
75
76    /// Read `OLLAMA_HOST` if set, otherwise [`DEFAULT_HOST`].
77    ///
78    /// Named for the variable Ollama's own tooling uses, so an operator who has
79    /// already pointed their CLI at a non-default instance does not have to
80    /// configure this separately.
81    pub fn host_from_env() -> String {
82        std::env::var("OLLAMA_HOST").unwrap_or_else(|_| DEFAULT_HOST.to_string())
83    }
84}
85
86#[async_trait]
87impl InferBackend for OllamaBackend {
88    async fn infer(
89        &self,
90        req: InferRequest<'_>,
91        on_token: &mut (dyn for<'t> FnMut(&'t str) -> bool + Send),
92    ) -> anyhow::Result<InferResult> {
93        // Ollama takes images as base64 strings alongside the prompt. Whether
94        // the *model* can use them is Ollama's business — asking a text-only
95        // model produces a clear error from it, which is more useful than a
96        // guess made here from a model name.
97        let images: Vec<String> = req
98            .images
99            .iter()
100            .map(|bytes| {
101                use base64::Engine;
102                base64::engine::general_purpose::STANDARD.encode(bytes)
103            })
104            .collect();
105
106        let response = self
107            .client
108            .post(format!("{}/api/generate", self.host))
109            .json(&serde_json::json!({
110                "model": self.model,
111                "prompt": req.prompt,
112                "stream": true,
113                "images": images,
114                "options": { "num_predict": req.max_tokens },
115            }))
116            .send()
117            .await
118            .map_err(|e| {
119                // The overwhelmingly likely cause is that Ollama is not running,
120                // and reqwest's own message does not say so.
121                anyhow::anyhow!(
122                    "could not reach Ollama at {} ({e}). Is it running? \
123                     Set OLLAMA_HOST to point elsewhere.",
124                    self.host
125                )
126            })?;
127
128        if !response.status().is_success() {
129            let status = response.status();
130            let body = response.text().await.unwrap_or_default();
131            anyhow::bail!("Ollama returned {status}: {body}");
132        }
133
134        let mut text = String::new();
135        let mut tokens_in = 0;
136        let mut tokens_out = 0;
137
138        // Responses are newline-delimited JSON, and a chunk boundary need not
139        // fall on a newline — a line can arrive split across two chunks, and one
140        // chunk can carry several lines. Buffering and splitting on '\n' is what
141        // makes this correct rather than usually-correct.
142        let mut stream = response.bytes_stream();
143        let mut buf = String::new();
144
145        'outer: while let Some(chunk) = stream.next().await {
146            buf.push_str(&String::from_utf8_lossy(&chunk?));
147
148            while let Some(newline) = buf.find('\n') {
149                // Take everything before the newline; leave the remainder in the
150                // buffer as the start of the next line.
151                let line: String = buf.drain(..=newline).collect();
152                let line = line.trim();
153                if line.is_empty() {
154                    continue;
155                }
156
157                let chunk: Chunk = serde_json::from_str(line).map_err(|e| {
158                    anyhow::anyhow!("malformed response from Ollama: {e} in {line}")
159                })?;
160
161                if let Some(error) = chunk.error {
162                    anyhow::bail!("Ollama error: {error}");
163                }
164
165                if !chunk.response.is_empty() {
166                    text.push_str(&chunk.response);
167                    tokens_out += 1;
168                    if !on_token(&chunk.response) {
169                        // The guest asked to stop. Dropping the stream closes
170                        // the connection, which is how Ollama learns to stop
171                        // generating — there is no separate cancel call.
172                        break 'outer;
173                    }
174                }
175
176                if chunk.done {
177                    // The final message carries authoritative counts; prefer
178                    // them over the tokens counted above, which only sees
179                    // non-empty responses.
180                    tokens_in = chunk.prompt_eval_count.unwrap_or(0);
181                    tokens_out = chunk.eval_count.unwrap_or(tokens_out);
182                    break 'outer;
183                }
184            }
185        }
186
187        Ok(InferResult {
188            text,
189            tokens_in,
190            tokens_out,
191        })
192    }
193
194    fn model_name(&self) -> String {
195        self.model.clone()
196    }
197
198    /// True, but the honest claim is narrower than the signature allows:
199    /// whether images work depends on the *model*, not on this backend, and
200    /// this type does not know which model it is pointed at until a request is
201    /// made.
202    ///
203    /// Forwarding is nonetheless the better behaviour, because Ollama answers
204    /// the question authoritatively and specifically — HTTP 400 with
205    /// "Multimodal data provided, but model does not support multimodal
206    /// requests" (verified against llama3.2:1b). Guessing here from a model
207    /// name would produce a worse message and a new way to be wrong, since the
208    /// set of vision models changes without this code changing.
209    fn supports_images(&self) -> bool {
210        true
211    }
212}
213
214/// Builds [`OllamaBackend`], registered as the `ollama` provider.
215pub struct OllamaFactory;
216
217impl crate::backend::BackendFactory for OllamaFactory {
218    fn provider(&self) -> &'static str {
219        "ollama"
220    }
221
222    fn describe(&self) -> &'static str {
223        "a local Ollama instance; target is a model tag such as `llama3.2:1b`"
224    }
225
226    fn build(&self, target: &str) -> anyhow::Result<std::sync::Arc<dyn InferBackend>> {
227        if target.is_empty() {
228            anyhow::bail!("an Ollama model name is required, e.g. `llama3.2:1b`");
229        }
230        // Reachability is deliberately not checked here — see `BackendFactory`.
231        Ok(std::sync::Arc::new(OllamaBackend::new(
232            OllamaBackend::host_from_env(),
233            target,
234        )))
235    }
236}