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}