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}