Skip to main content

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}