Skip to main content

kopitiam_ai/local/
adapter.rs

1//! [`LocalAdapter`] itself: the seam where `kopitiam-ai` actually touches
2//! `kopitiam-runtime`, `kopitiam-loader`, and `kopitiam-tokenizer`.
3//!
4//! # Why `kopitiam-ai` is allowed to depend on `kopitiam-runtime`
5//!
6//! CLAUDE.md's Semantic Runtime dependency rule reads: "Nothing below
7//! `kopitiam-workflow` may depend on `kopitiam-ai`." That sentence
8//! constrains what depends *on* `kopitiam-ai` — it says nothing about what
9//! `kopitiam-ai` itself may depend on. `kopitiam-runtime` (and the
10//! `kopitiam-core`/`kopitiam-tensor`/`kopitiam-loader`/`kopitiam-tokenizer`
11//! stack beneath it) is not one of the Semantic Runtime crates CLAUDE.md's
12//! architecture table names at all — it is a separate stack, and this
13//! workspace's own root `Cargo.toml` says so explicitly, in the comment
14//! directly above where that stack is declared: "the CPU-first,
15//! local-first inference engine that will sit *behind* `kopitiam-ai`'s
16//! `ModelAdapter`." So `kopitiam-ai -> kopitiam-runtime` is a downward
17//! dependency into a lower, independent stack — the same shape as
18//! `kopitiam-ai -> serde` or `kopitiam-ai -> anyhow` — not a violation of
19//! a rule that is only ever about the *upward* direction (nothing may
20//! reach back into `kopitiam-ai` from underneath `kopitiam-workflow`).
21//! `kopitiam-runtime` itself has no dependency on `kopitiam-ai` (see its
22//! `Cargo.toml`), so no cycle is possible either way, and
23//! `kopitiam-workflow` still only ever sees this through the
24//! [`crate::ModelAdapter`] trait object — it never names `LocalAdapter` or
25//! `kopitiam-runtime` directly.
26//!
27//! Put differently: had this instead needed a separate `kopitiam-ai-local`
28//! crate, the same question would recur one level up (does
29//! `kopitiam-workflow` depending on *that* violate the rule?) with the
30//! same answer, for the same reason — the rule is about not depending back
31//! on `kopitiam-ai` from underneath it, and neither shape does that. A
32//! split crate would only be justified by a *different* concern (e.g.
33//! wanting `kopitiam-ai` itself to compile without ever pulling in a
34//! tensor engine, even optionally) — which is exactly what the `local`
35//! Cargo feature already achieves without a second crate: `cargo build
36//! -p kopitiam-ai --no-default-features` compiles the trait and
37//! [`crate::EchoAdapter`] alone, with no `kopitiam-runtime` in the
38//! dependency graph at all.
39
40use std::path::Path;
41use std::sync::Arc;
42use std::sync::mpsc::Receiver;
43
44use anyhow::{Context, Result};
45use kopitiam_runtime::{
46    GenerationConfig, QwenModel, StochasticSampler, generate_with_sampler, tokenizer_from_gguf,
47};
48use kopitiam_tokenizer::BpeTokenizer;
49
50use super::chat_template::render_chatml;
51use super::generation::{default_sampling, resolve_eos_token_id, resolve_max_new_tokens};
52use crate::{CompletionRequest, CompletionResponse, ModelAdapter, StreamChunk};
53
54const IM_END: &str = "<|im_end|>";
55const ENDOFTEXT: &str = "<|endoftext|>";
56/// GGUF's own declared default end-of-sequence id, when present — see
57/// [`super::generation::resolve_eos_token_id`]'s docs for why this is
58/// consulted at all despite `<|im_end|>` being preferred.
59const GGUF_EOS_METADATA_KEY: &str = "tokenizer.ggml.eos_token_id";
60
61/// A [`crate::ModelAdapter`] that runs a GGUF Qwen model entirely on this
62/// machine's CPU via `kopitiam-runtime` — no network, no cloud account, no
63/// API key. This is what makes CLAUDE.md's Offline First pipeline
64/// ("existing knowledge, then native Rust, then local AI, then cloud AI as
65/// the final fallback") a real, callable rung rather than a promise:
66/// before this type existed, "local AI" had nothing behind it but
67/// [`crate::EchoAdapter`], which invokes no model at all.
68///
69/// Construct via [`LocalAdapter::load`]; see this module's docs for why
70/// depending on `kopitiam-runtime` here does not violate the Semantic
71/// Runtime's dependency rule.
72pub struct LocalAdapter {
73    /// The loaded model, behind an [`Arc`] so [`LocalAdapter::stream`] can
74    /// hand a cheap clone to its background generation thread without moving
75    /// the (large) weights or borrowing `self` across the thread boundary.
76    /// `complete` just derefs it on the calling thread. `QwenModel` is only
77    /// ever read during a forward pass (`&self`), so sharing it across
78    /// threads is sound.
79    model: Arc<QwenModel>,
80    /// The GGUF-embedded tokenizer, [`Arc`]-shared for the same reason as
81    /// [`LocalAdapter::model`].
82    tokenizer: Arc<BpeTokenizer>,
83    /// Names the specific model this adapter serves, resolved once at
84    /// [`LocalAdapter::load`] time from the GGUF's own `general.name`
85    /// metadata (falling back to `general.architecture`, then a generic
86    /// label). This is what [`CompletionResponse::model`] reports —
87    /// deliberately distinct from [`LocalAdapter::name`]; see
88    /// [`crate::ModelAdapter::name`]'s docs on that distinction.
89    model_name: String,
90    /// The single stop token [`GenerationConfig::eos_token_id`] generates
91    /// against, resolved once at load time — see
92    /// [`super::generation::resolve_eos_token_id`].
93    eos_token_id: Option<u32>,
94}
95
96impl LocalAdapter {
97    /// Whether the output projection is running on the GPU for this model.
98    ///
99    /// Exposed so the CLI can tell the user which path it actually took. The
100    /// notice used to say "on CPU" unconditionally, which stopped being true
101    /// the moment the output head could be offloaded — and a status line that
102    /// is confidently wrong is worse than no status line.
103    #[must_use]
104    pub fn gpu_output_head_active(&self) -> bool {
105        self.model.gpu_output_head_active()
106    }
107
108    /// Loads a GGUF Qwen model and its embedded tokenizer from `path`,
109    /// once, and holds both for the lifetime of the returned adapter.
110    ///
111    /// # Errors
112    ///
113    /// Returns `Err` — never panics — if `path` does not exist, is not a
114    /// valid GGUF/SafeTensors file, is missing metadata
115    /// [`kopitiam_runtime::QwenConfig::from_metadata`] requires, or has no
116    /// embedded `tokenizer.ggml.tokens` vocabulary
117    /// ([`tokenizer_from_gguf`]'s own error cases). This is deliberate,
118    /// not incidental: per [`crate::ModelAdapter::complete`]'s own docs, a
119    /// failing adapter is how `kopitiam-workflow` falls back to the next
120    /// rung of the Offline First pipeline, so every failure mode here has
121    /// to surface as `Err` rather than a panic that would take the whole
122    /// workflow process down with it.
123    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
124        let path = path.as_ref();
125        let loaded = kopitiam_loader::load_model(path)
126            .with_context(|| format!("loading model file {}", path.display()))?;
127
128        let model = QwenModel::from_loaded_model(&loaded)
129            .with_context(|| format!("building a Qwen model from {}", path.display()))?;
130
131        let tokenizer = tokenizer_from_gguf(&loaded).with_context(|| {
132            format!("building a tokenizer from {}'s embedded GGUF vocabulary", path.display())
133        })?;
134
135        let model_name = loaded
136            .metadata()
137            .name
138            .clone()
139            .or_else(|| loaded.metadata().architecture.clone())
140            .unwrap_or_else(|| "local-gguf-model".to_string());
141
142        let gguf_eos_metadata = loaded.metadata().raw.get_u32(GGUF_EOS_METADATA_KEY);
143        let eos_token_id = resolve_eos_token_id(
144            tokenizer.special_token_id(IM_END),
145            gguf_eos_metadata,
146            tokenizer.special_token_id(ENDOFTEXT),
147        );
148
149        Ok(Self { model: Arc::new(model), tokenizer: Arc::new(tokenizer), model_name, eos_token_id })
150    }
151}
152
153impl ModelAdapter for LocalAdapter {
154    fn name(&self) -> &str {
155        "local-qwen"
156    }
157
158    fn complete(&self, request: &CompletionRequest) -> Result<CompletionResponse> {
159        let prompt = render_chatml(&request.messages);
160        let config = GenerationConfig {
161            max_new_tokens: resolve_max_new_tokens(request.max_tokens),
162            eos_token_id: self.eos_token_id,
163        };
164
165        // Stochastic, NOT greedy: see `super::generation::default_sampling` for
166        // the degenerate-repetition bug greedy argmax caused here, and for why
167        // every number in that config is ollama's rather than ours.
168        let mut sampler = StochasticSampler::new(default_sampling());
169        let content = generate_with_sampler(
170            &*self.model,
171            &*self.tokenizer,
172            &prompt,
173            &config,
174            &mut sampler,
175            |_id, _text| {},
176        )
177        .context("local model generation failed")?;
178
179        Ok(CompletionResponse { content, model: self.model_name.clone() })
180    }
181
182    /// Streams the model's reply **token by token** off a background thread,
183    /// so a chat UI renders each token as `kopitiam-runtime` decodes it
184    /// instead of freezing for the whole generation — the non-negotiable
185    /// shape on a phone doing a few tokens per second (`temp_ai_design.md`
186    /// §10.4).
187    ///
188    /// # How it stays off the foreground thread
189    ///
190    /// The heavy state — model weights and tokenizer — lives behind [`Arc`]s
191    /// (see [`LocalAdapter::model`]), so this clones the two `Arc`s (cheap:
192    /// two refcount bumps, no weight copy) and **moves** them into a spawned
193    /// std thread. That thread owns the channel's `Sender` and runs
194    /// [`generate`], whose `on_token` callback fires once per decoded token;
195    /// each fire sends a [`StreamChunk::Token`]. On a clean finish it sends
196    /// [`StreamChunk::Done`]; if `generate` errors it sends
197    /// [`StreamChunk::Error`] instead. This is the AID-0028 actor discipline
198    /// (worker owns the resource, streams over a channel, never blocks the
199    /// caller) — no async runtime, just a thread and an `mpsc`.
200    ///
201    /// Moving the `Arc`s (rather than borrowing `self`) is what lets the
202    /// thread outlive this call safely: it holds its own strong references,
203    /// so the weights stay alive for exactly as long as generation needs
204    /// them even if the `LocalAdapter` itself is dropped meanwhile.
205    ///
206    /// # If the caller stops listening
207    ///
208    /// `generate`'s `on_token` can't itself abort the loop, so if the
209    /// receiver is dropped mid-reply the `send` starts failing but generation
210    /// runs to its `max_new_tokens`/EOS on the background thread before the
211    /// thread exits. Wasted compute, never a hang or a panic — acceptable for
212    /// phase 1; a cancellation token is a later refinement.
213    fn stream(&self, request: &CompletionRequest) -> Receiver<StreamChunk> {
214        let (tx, rx) = std::sync::mpsc::channel();
215
216        // Everything the thread needs, resolved on the calling thread so the
217        // closure captures only owned, `Send` values (Arcs, a String, a
218        // small Copy config) — nothing borrowed from `self` or `request`.
219        let prompt = render_chatml(&request.messages);
220        let config = GenerationConfig {
221            max_new_tokens: resolve_max_new_tokens(request.max_tokens),
222            eos_token_id: self.eos_token_id,
223        };
224        let model = Arc::clone(&self.model);
225        let tokenizer = Arc::clone(&self.tokenizer);
226
227        std::thread::spawn(move || {
228            // Same sampler as `complete` — a streamed reply and a batched one
229            // must not decode differently, or a bug reproduces in one and not
230            // the other.
231            let mut sampler = StochasticSampler::new(default_sampling());
232            let result = generate_with_sampler(
233                &*model,
234                &*tokenizer,
235                &prompt,
236                &config,
237                &mut sampler,
238                |_id, text| {
239                    // Ignore send errors: they only happen once the receiver is
240                    // gone, and there's nothing further to deliver to.
241                    let _ = tx.send(StreamChunk::Token(text.to_string()));
242                },
243            );
244            match result {
245                Ok(_) => {
246                    let _ = tx.send(StreamChunk::Done);
247                }
248                Err(error) => {
249                    let _ = tx.send(StreamChunk::Error(format!("{error:#}")));
250                }
251            }
252        });
253
254        rx
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::Message;
262    use crate::local::test_support::synthetic_gguf::{build_local_adapter_fixture, write_temp_gguf};
263
264    #[test]
265    fn load_on_a_nonexistent_path_returns_err_not_a_panic() {
266        let result = LocalAdapter::load("/does/not/exist/kopitiam-nonexistent.gguf");
267        assert!(result.is_err());
268    }
269
270    #[test]
271    fn load_on_a_non_gguf_file_returns_err_not_a_panic() {
272        let dir = tempfile::tempdir().unwrap();
273        let path = dir.path().join("not-a-model.gguf");
274        std::fs::write(&path, b"this is plainly not a GGUF or SafeTensors file").unwrap();
275
276        let result = LocalAdapter::load(&path);
277        assert!(result.is_err());
278    }
279
280    /// The strongest test available without real model weights (none
281    /// exist on this machine — see this crate's task brief): a tiny but
282    /// structurally real synthetic GGUF, built the same way
283    /// `kopitiam-runtime`'s own tests build one, but extended with a full
284    /// byte-level vocabulary plus the three ChatML control tokens so it
285    /// exercises `LocalAdapter::load`'s *entire* pipeline — GGUF parse,
286    /// `QwenModel` construction, GGUF-embedded tokenizer construction,
287    /// `general.name` resolution, and EOS token resolution — followed by
288    /// a real `complete()` call through ChatML rendering and
289    /// `kopitiam_runtime::generate`'s forward passes. It intentionally
290    /// does not assert anything about *which* tokens random weights
291    /// favor — that would be asserting a coincidence, not a property of
292    /// the code — only that the whole path runs, honors `max_tokens`, and
293    /// reports the model name from GGUF metadata rather than the
294    /// adapter's own name.
295    #[test]
296    fn end_to_end_against_a_synthetic_gguf_with_no_real_weights() {
297        let bytes = build_local_adapter_fixture();
298        let path = write_temp_gguf(&bytes, "local-adapter-e2e");
299
300        let adapter = LocalAdapter::load(&path).expect("synthetic GGUF must load");
301        assert_eq!(adapter.name(), "local-qwen");
302
303        let request = CompletionRequest::new([
304            Message::system("you are a test fixture"),
305            Message::user("hello"),
306        ])
307        .with_max_tokens(5);
308
309        let response = adapter.complete(&request).expect("generation against synthetic weights must not error");
310        // The synthetic GGUF's general.name, not LocalAdapter::name()'s
311        // "local-qwen" -- proving CompletionResponse::model reports the
312        // model, not the adapter.
313        assert_eq!(response.model, "kopitiam-test-qwen");
314    }
315
316    /// `max_tokens` must actually bound generation length. Greedy decoding
317    /// against a fixed synthetic model and fixed prompt is deterministic
318    /// (the same property `kopitiam-runtime`'s own KV-cache tests rely
319    /// on), so requesting more tokens can only ever produce a
320    /// longer-or-equal completion than requesting fewer, never shorter.
321    #[test]
322    fn max_tokens_bounds_the_completion_length() {
323        let bytes = build_local_adapter_fixture();
324        let path = write_temp_gguf(&bytes, "local-adapter-max-tokens");
325        let adapter = LocalAdapter::load(&path).unwrap();
326
327        let short = adapter
328            .complete(&CompletionRequest::new([Message::user("hi")]).with_max_tokens(1))
329            .unwrap();
330        let long = adapter
331            .complete(&CompletionRequest::new([Message::user("hi")]).with_max_tokens(20))
332            .unwrap();
333
334        assert!(
335            long.content.len() >= short.content.len(),
336            "a larger max_tokens budget must never produce a shorter completion \
337             (short={:?}, long={:?})",
338            short.content,
339            long.content
340        );
341    }
342
343    /// Streaming must honour the [`StreamChunk`] contract end to end against
344    /// a real (synthetic) model: some [`StreamChunk::Token`]s arrive and the
345    /// run ends with **exactly one** terminal chunk — a [`StreamChunk::Done`]
346    /// (a synthetic forward pass doesn't error), last, with no
347    /// [`StreamChunk::Error`] anywhere.
348    ///
349    /// It also checks the streamed-token count matches the number of tokens
350    /// the greedy loop generates — with `max_tokens = 8` and deterministic
351    /// greedy decoding, `stream` fires `on_token` once per generated token,
352    /// so the `Token` count is the generated-token count (0 only if the very
353    /// first sampled token is EOS). It deliberately does **not** assert the
354    /// concatenated text equals [`LocalAdapter::complete`]'s string: `stream`
355    /// decodes token-by-token while `complete` decodes the whole sequence at
356    /// once, and per [`StreamChunk::Token`]'s own docs a multi-byte Unicode
357    /// character split across two tokens can legitimately differ between the
358    /// two — asserting byte-exact equality would be asserting the *absence*
359    /// of that accepted behaviour, which the random synthetic weights don't
360    /// guarantee.
361    #[test]
362    fn stream_is_wellformed_against_a_synthetic_gguf() {
363        let bytes = build_local_adapter_fixture();
364        let path = write_temp_gguf(&bytes, "local-adapter-stream");
365        let adapter = LocalAdapter::load(&path).unwrap();
366
367        let request = CompletionRequest::new([
368            Message::system("you are a test fixture"),
369            Message::user("hello"),
370        ])
371        .with_max_tokens(8);
372
373        let chunks: Vec<StreamChunk> = adapter.stream(&request).iter().collect();
374
375        // Exactly one terminal chunk, it's last, and it's `Done` — no `Error`.
376        assert_eq!(chunks.last(), Some(&StreamChunk::Done));
377        assert_eq!(chunks.iter().filter(|c| **c == StreamChunk::Done).count(), 1);
378        assert!(!chunks.iter().any(|c| matches!(c, StreamChunk::Error(_))));
379
380        // Every non-terminal chunk is a `Token`, and there are at most
381        // `max_tokens` of them (greedy loop is bounded by the budget).
382        let token_count = chunks
383            .iter()
384            .filter(|c| matches!(c, StreamChunk::Token(_)))
385            .count();
386        assert_eq!(token_count, chunks.len() - 1, "every chunk but the terminal Done must be a Token");
387        assert!(token_count <= 8, "generated more tokens than the max_tokens budget");
388    }
389
390    /// Real, full-size Qwen `.gguf` weights were not found anywhere on
391    /// this machine when this test was written (per this crate's task
392    /// brief: "no model weights exist on this machine... do NOT download
393    /// anything"). This test is `#[ignore]`d rather than deleted, ready to
394    /// run the moment a real model is available -- mirrors
395    /// `kopitiam_runtime`'s own `a_real_model_on_disk_is_used_if_present`
396    /// pattern (`crates/kopitiam-runtime/src/model.rs`), one environment
397    /// variable pointing at a `.gguf` file rather than hand-rolling a
398    /// second "load and generate" from scratch.
399    ///
400    /// Run with:
401    /// `KOPITIAM_QWEN_GGUF=/path/to/model.gguf cargo test --release -p kopitiam-ai -- --ignored`
402    #[test]
403    #[ignore = "no real Qwen GGUF present on this machine; point KOPITIAM_QWEN_GGUF at one to run this"]
404    fn a_real_local_model_answers_a_chatml_prompt() {
405        let path = std::env::var("KOPITIAM_QWEN_GGUF").expect("set KOPITIAM_QWEN_GGUF to a real Qwen .gguf file");
406        let adapter = LocalAdapter::load(&path).expect("a real Qwen GGUF must load");
407
408        let request = CompletionRequest::new([
409            Message::system("You are a helpful assistant."),
410            Message::user("Say hello in one short sentence."),
411        ])
412        .with_max_tokens(64);
413
414        let response = adapter.complete(&request).expect("a real Qwen model must generate a completion");
415        assert!(!response.content.trim().is_empty(), "a real model should not answer with empty text");
416        assert_ne!(response.model, adapter.name(), "CompletionResponse::model must name the model, not the adapter");
417        println!("real model {:?} answered: {:?}", response.model, response.content);
418    }
419}