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
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}