cuttlefish_host/infer.rs
1//! Where inference comes from.
2//!
3//! The runner talks to models only through [`InferBackend`], so the whole
4//! reactor loop can be tested without a model. llama.cpp arrives behind this
5//! same trait later; nothing above it should need to change.
6
7use async_trait::async_trait;
8
9/// What one completed generation produced.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct InferResult {
12 /// The generated text.
13 pub text: String,
14 /// Tokens consumed by the prompt.
15 pub tokens_in: u32,
16 /// Tokens generated.
17 pub tokens_out: u32,
18}
19
20/// One inference request.
21///
22/// A struct rather than a growing parameter list. `images` was added after the
23/// fact and would have broken every implementation had it been positional;
24/// temperature, grammars, and stop sequences are all coming, and each would do
25/// the same. Adding a field here with a sensible default does not.
26#[derive(Debug, Default)]
27pub struct InferRequest<'a> {
28 /// What to generate from.
29 pub prompt: &'a str,
30 /// Upper bound on generated tokens.
31 pub max_tokens: u32,
32 /// Images accompanying the prompt, already loaded by the host.
33 ///
34 /// Empty for ordinary text inference. A backend whose model has no vision
35 /// capability should fail loudly rather than ignore these — silently
36 /// dropping an image produces an answer about nothing, which is worse than
37 /// an error because it looks like a bad model.
38 pub images: &'a [Vec<u8>],
39}
40
41impl<'a> InferRequest<'a> {
42 /// A text-only request.
43 pub fn new(prompt: &'a str, max_tokens: u32) -> Self {
44 Self {
45 prompt,
46 max_tokens,
47 images: &[],
48 }
49 }
50}
51
52/// Anything that can serve an inference request.
53#[async_trait]
54pub trait InferBackend: Send + Sync {
55 /// Generate from `req.prompt`, invoking `on_token` once per token.
56 ///
57 /// `on_token` returns whether to keep going; returning `false` ends
58 /// generation early, which is how a guest's `Stop` verdict is honoured.
59 ///
60 /// The `for<'t>` is load-bearing. `#[async_trait]` rewrites elided lifetimes
61 /// into named ones, which would make this closure non-generic over the
62 /// token's lifetime and leave implementations unable to hand it a local
63 /// `&str` — an E0597 that appears only in the implementor, with a message
64 /// that does not obviously point back here.
65 async fn infer(
66 &self,
67 req: InferRequest<'_>,
68 on_token: &mut (dyn for<'t> FnMut(&'t str) -> bool + Send),
69 ) -> anyhow::Result<InferResult>;
70
71 /// Identifier recorded in the job's usage accounting.
72 fn model_name(&self) -> String;
73
74 /// Whether this backend can accept images.
75 ///
76 /// Defaults to false, so a backend added without thinking about vision
77 /// refuses images rather than quietly discarding them.
78 fn supports_images(&self) -> bool {
79 false
80 }
81
82 /// Embed each of `texts`, returning one vector per input, in order.
83 ///
84 /// Batched rather than one call per text, and that is the whole point:
85 /// embedding a corpus means tens of thousands of chunks, and a round
86 /// trip each turns minutes into hours. Ollama's `/api/embed` accepts an
87 /// array natively, so the batch is real rather than a loop wearing a
88 /// batch's clothes.
89 ///
90 /// The default refuses. A backend that cannot embed must say so rather
91 /// than return empty vectors, which downstream would store as a valid
92 /// row and quietly poison every similarity search made against it.
93 async fn embed(&self, _texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
94 anyhow::bail!(
95 "this backend ({}) cannot produce embeddings. Declare an \
96 `embedding_model` your provider serves — with Ollama, a model built for it \
97 such as `nomic-embed-text`, not a chat model.",
98 self.model_name()
99 )
100 }
101
102 /// Whether [`InferBackend::embed`] will do anything.
103 fn supports_embeddings(&self) -> bool {
104 false
105 }
106}
107
108/// A deterministic fake that streams a fixed reply word by word.
109///
110/// Enough to exercise streaming, early-stop, and token accounting with no model
111/// present. Registered as the `stub` provider, so a spec can select it with
112/// `model = Stub "anything"` — which makes it possible to test a pipeline end to
113/// end without depending on a model's wording, or on a model at all.
114pub struct StubBackend {
115 /// What every request generates.
116 pub reply: String,
117}
118
119impl Default for StubBackend {
120 fn default() -> Self {
121 Self {
122 reply: "a stub summary".into(),
123 }
124 }
125}
126
127#[async_trait]
128impl InferBackend for StubBackend {
129 async fn infer(
130 &self,
131 req: InferRequest<'_>,
132 on_token: &mut (dyn for<'t> FnMut(&'t str) -> bool + Send),
133 ) -> anyhow::Result<InferResult> {
134 let mut out = String::new();
135 let mut tokens_out = 0u32;
136
137 // Make the images visible in the output. A caller asserting on the
138 // result can then tell whether they actually arrived, instead of
139 // getting a plausible answer that ignored them.
140 let reply = if req.images.is_empty() {
141 self.reply.clone()
142 } else {
143 format!("[{} image(s)] {}", req.images.len(), self.reply)
144 };
145
146 for word in reply.split_whitespace().take(req.max_tokens as usize) {
147 let piece = if out.is_empty() {
148 word.to_string()
149 } else {
150 format!(" {word}")
151 };
152 tokens_out += 1;
153
154 let keep_going = on_token(&piece);
155 out.push_str(&piece);
156 if !keep_going {
157 break;
158 }
159
160 // Yielding between tokens is not cosmetic. Without an await point
161 // the entire loop runs inside a single poll, every token lands in
162 // the channel at once, and the host can never interleave a guest's
163 // Stop verdict — the early-stop path would look implemented while
164 // being unreachable. A real backend awaits naturally; this one has
165 // to do it deliberately.
166 tokio::task::yield_now().await;
167 }
168
169 Ok(InferResult {
170 text: out,
171 tokens_in: req.prompt.split_whitespace().count() as u32,
172 tokens_out,
173 })
174 }
175
176 fn model_name(&self) -> String {
177 "stub".into()
178 }
179
180 /// The stub accepts images so a multimodal pipeline is testable without a
181 /// vision model — but it *reports* what it received rather than discarding
182 /// it. Silently ignoring images is the failure this whole guard exists to
183 /// prevent; a test backend that does it cannot catch anyone else doing it.
184 fn supports_images(&self) -> bool {
185 true
186 }
187}
188
189/// Builds [`StubBackend`], registered as the `stub` provider.
190pub struct StubFactory;
191
192impl crate::backend::BackendFactory for StubFactory {
193 fn provider(&self) -> &'static str {
194 "stub"
195 }
196
197 fn describe(&self) -> &'static str {
198 "deterministic canned responses; no model required"
199 }
200
201 /// The target becomes the reply, so a spec can choose what the stub says.
202 /// An empty target keeps the default, which is what most callers want.
203 fn build(&self, target: &str) -> anyhow::Result<std::sync::Arc<dyn InferBackend>> {
204 Ok(std::sync::Arc::new(if target.is_empty() {
205 StubBackend::default()
206 } else {
207 StubBackend {
208 reply: target.to_string(),
209 }
210 }))
211 }
212}