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
83/// A deterministic fake that streams a fixed reply word by word.
84///
85/// Enough to exercise streaming, early-stop, and token accounting with no model
86/// present. Registered as the `stub` provider, so a spec can select it with
87/// `model = Stub "anything"` — which makes it possible to test a pipeline end to
88/// end without depending on a model's wording, or on a model at all.
89pub struct StubBackend {
90 /// What every request generates.
91 pub reply: String,
92}
93
94impl Default for StubBackend {
95 fn default() -> Self {
96 Self {
97 reply: "a stub summary".into(),
98 }
99 }
100}
101
102#[async_trait]
103impl InferBackend for StubBackend {
104 async fn infer(
105 &self,
106 req: InferRequest<'_>,
107 on_token: &mut (dyn for<'t> FnMut(&'t str) -> bool + Send),
108 ) -> anyhow::Result<InferResult> {
109 let mut out = String::new();
110 let mut tokens_out = 0u32;
111
112 // Make the images visible in the output. A caller asserting on the
113 // result can then tell whether they actually arrived, instead of
114 // getting a plausible answer that ignored them.
115 let reply = if req.images.is_empty() {
116 self.reply.clone()
117 } else {
118 format!("[{} image(s)] {}", req.images.len(), self.reply)
119 };
120
121 for word in reply.split_whitespace().take(req.max_tokens as usize) {
122 let piece = if out.is_empty() {
123 word.to_string()
124 } else {
125 format!(" {word}")
126 };
127 tokens_out += 1;
128
129 let keep_going = on_token(&piece);
130 out.push_str(&piece);
131 if !keep_going {
132 break;
133 }
134
135 // Yielding between tokens is not cosmetic. Without an await point
136 // the entire loop runs inside a single poll, every token lands in
137 // the channel at once, and the host can never interleave a guest's
138 // Stop verdict — the early-stop path would look implemented while
139 // being unreachable. A real backend awaits naturally; this one has
140 // to do it deliberately.
141 tokio::task::yield_now().await;
142 }
143
144 Ok(InferResult {
145 text: out,
146 tokens_in: req.prompt.split_whitespace().count() as u32,
147 tokens_out,
148 })
149 }
150
151 fn model_name(&self) -> String {
152 "stub".into()
153 }
154
155 /// The stub accepts images so a multimodal pipeline is testable without a
156 /// vision model — but it *reports* what it received rather than discarding
157 /// it. Silently ignoring images is the failure this whole guard exists to
158 /// prevent; a test backend that does it cannot catch anyone else doing it.
159 fn supports_images(&self) -> bool {
160 true
161 }
162}
163
164/// Builds [`StubBackend`], registered as the `stub` provider.
165pub struct StubFactory;
166
167impl crate::backend::BackendFactory for StubFactory {
168 fn provider(&self) -> &'static str {
169 "stub"
170 }
171
172 fn describe(&self) -> &'static str {
173 "deterministic canned responses; no model required"
174 }
175
176 /// The target becomes the reply, so a spec can choose what the stub says.
177 /// An empty target keeps the default, which is what most callers want.
178 fn build(&self, target: &str) -> anyhow::Result<std::sync::Arc<dyn InferBackend>> {
179 Ok(std::sync::Arc::new(if target.is_empty() {
180 StubBackend::default()
181 } else {
182 StubBackend {
183 reply: target.to_string(),
184 }
185 }))
186 }
187}