Skip to main content

studio_worker/engine/
llama.rs

1//! Real LLM inference via [`llama-cpp-2`].
2//!
3//! Compiled in via `--features llama`.  Reads every `*.gguf` it can
4//! find under `<models_root>/llm/` and exposes the filename stem as a
5//! model id.  When a job's `model` matches, the engine loads it on
6//! demand (with an LRU of size 1 — keep the most recently used model
7//! resident in VRAM/RAM), runs the generation, and returns
8//! `chat.completion`-shaped JSON.
9use crate::engine::{Engine, EngineCapabilities};
10use crate::types::*;
11use anyhow::{anyhow, bail, Context, Result};
12use llama_cpp_2::context::params::LlamaContextParams;
13use llama_cpp_2::llama_backend::LlamaBackend;
14use llama_cpp_2::llama_batch::LlamaBatch;
15use llama_cpp_2::model::params::LlamaModelParams;
16use llama_cpp_2::model::{AddBos, LlamaModel};
17use llama_cpp_2::sampling::LlamaSampler;
18use parking_lot::Mutex;
19use std::collections::BTreeMap;
20use std::num::NonZeroU32;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::time::Instant;
24use tracing::{debug, info, warn};
25
26/// Tracing target for the llama engine.  Stable so operators can
27/// filter with `RUST_LOG=studio_worker::engine::llama=debug`.
28const TRACE_TARGET: &str = "studio_worker::engine::llama";
29
30pub struct LlamaEngine {
31    backend: Arc<LlamaBackend>,
32    models_root: PathBuf,
33    cached: Mutex<Option<CachedModel>>,
34}
35
36// `LlamaBackend::init()` can only run once per process; subsequent calls
37// return `BackendAlreadyInitialized`.  We cache a single global handle so
38// multiple `LlamaEngine` constructions in the same binary share it.
39static GLOBAL_BACKEND: std::sync::OnceLock<Arc<LlamaBackend>> = std::sync::OnceLock::new();
40
41/// Serialises first-time backend init.  `OnceLock::get_or_init` can't
42/// host the init because `LlamaBackend::init()` is fallible; without
43/// this lock two threads race `init()` and the loser observes
44/// `BackendAlreadyInitialized` before the winner has published its
45/// handle — a check-then-act gap a bounded spin-wait used to lose on
46/// loaded CI runners.
47static BACKEND_INIT_LOCK: Mutex<()> = Mutex::new(());
48
49fn global_backend() -> Result<Arc<LlamaBackend>> {
50    if let Some(b) = GLOBAL_BACKEND.get() {
51        return Ok(b.clone());
52    }
53    let _guard = BACKEND_INIT_LOCK.lock();
54    // Re-check under the lock: another thread may have initialised and
55    // published while we waited for it.
56    if let Some(b) = GLOBAL_BACKEND.get() {
57        return Ok(b.clone());
58    }
59    let backend = LlamaBackend::init().map_err(|e| match e {
60        // With init serialised by the lock, this can only mean some
61        // other code path called `LlamaBackend::init()` directly — we
62        // have no handle to share, so surface it loudly.
63        llama_cpp_2::LlamaCppError::BackendAlreadyInitialized => anyhow!(
64            "llama backend was initialised outside global_backend(); no shared handle available"
65        ),
66        other => anyhow!(other),
67    })?;
68    let arc = Arc::new(backend);
69    let _ = GLOBAL_BACKEND.set(arc.clone());
70    Ok(arc)
71}
72
73struct CachedModel {
74    id: String,
75    model: Arc<LlamaModel>,
76}
77
78impl LlamaEngine {
79    pub fn new(models_root: PathBuf) -> Result<Self> {
80        let backend = global_backend().context("initialising llama backend")?;
81        Ok(Self {
82            backend,
83            models_root,
84            cached: Mutex::new(None),
85        })
86    }
87
88    fn llm_dir(&self) -> PathBuf {
89        self.models_root.join("llm")
90    }
91
92    fn list_models(&self) -> Vec<(String, PathBuf)> {
93        let dir = self.llm_dir();
94        let Ok(read) = std::fs::read_dir(&dir) else {
95            return Vec::new();
96        };
97        let mut out = Vec::new();
98        for entry in read.flatten() {
99            let p = entry.path();
100            if p.extension().and_then(|s| s.to_str()) == Some("gguf") {
101                if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
102                    out.push((stem.to_string(), p));
103                }
104            }
105        }
106        out
107    }
108
109    fn resolve_path(&self, model: &str) -> Option<PathBuf> {
110        self.list_models()
111            .into_iter()
112            .find(|(stem, _)| stem == model)
113            .map(|(_, p)| p)
114    }
115
116    fn load_or_get(&self, model: &str, path: &Path) -> Result<Arc<LlamaModel>> {
117        let mut guard = self.cached.lock();
118        if let Some(c) = &*guard {
119            if c.id == model {
120                debug!(
121                    target: TRACE_TARGET,
122                    op = "load",
123                    model,
124                    cache = "hit",
125                    "reusing cached model"
126                );
127                return Ok(c.model.clone());
128            }
129        }
130        info!(
131            target: TRACE_TARGET,
132            op = "load",
133            model,
134            path = %path.display(),
135            "loading model"
136        );
137        let started = Instant::now();
138        let params = LlamaModelParams::default();
139        let loaded = LlamaModel::load_from_file(&self.backend, path, &params)
140            .with_context(|| format!("loading model {} from {}", model, path.display()))
141            .inspect_err(|e| {
142                warn!(
143                    target: TRACE_TARGET,
144                    op = "load",
145                    model,
146                    path = %path.display(),
147                    elapsed_ms = started.elapsed().as_millis() as u64,
148                    error = %e,
149                    "failed to load model"
150                );
151            })?;
152        let elapsed_ms = started.elapsed().as_millis() as u64;
153        let arc = Arc::new(loaded);
154        *guard = Some(CachedModel {
155            id: model.to_string(),
156            model: arc.clone(),
157        });
158        info!(
159            target: TRACE_TARGET,
160            op = "load",
161            model,
162            elapsed_ms,
163            "model loaded"
164        );
165        Ok(arc)
166    }
167}
168
169fn render_prompt(messages: &[ChatMessage]) -> String {
170    // Minimal chat template: <role>: <content>\n…\nassistant:
171    let mut out = String::new();
172    for m in messages {
173        out.push_str(&format!("<|{}|>\n{}\n", m.role, m.content));
174    }
175    out.push_str("<|assistant|>\n");
176    out
177}
178
179/// Whether a prompt of `prompt_tokens` plus a `max_tokens` generation
180/// budget overflows the context window `n_ctx` (the KV-cache size).
181/// Pure so the over-budget guard is unit-tested without a loaded model.
182/// Saturating arithmetic keeps a pathological `max_tokens` from wrapping.
183fn exceeds_context_window(prompt_tokens: usize, max_tokens: u32, n_ctx: u32) -> bool {
184    prompt_tokens.saturating_add(max_tokens as usize) > n_ctx as usize
185}
186
187/// Append a sampled token's decoded text to the running completion.
188///
189/// `llama.cpp`'s `token_to_piece` occasionally fails to decode a token
190/// to UTF-8 text — a partial multi-byte sequence at a token boundary, or
191/// a byte-fallback piece the decoder rejects — which surfaces as an
192/// `Err`.  A failed piece is dropped from the completion (preserving the
193/// existing behaviour) but counted and warn-logged, so a truncated
194/// completion can never pass for a complete one and the "generation
195/// complete" breadcrumb reports the real `decode_failures`.  Generic
196/// over the error so it's unit-testable without a loaded model.
197fn append_piece<E: std::fmt::Display>(
198    out: &mut String,
199    step: usize,
200    piece: std::result::Result<String, E>,
201    decode_failures: &mut u32,
202) {
203    match piece {
204        Ok(s) => out.push_str(&s),
205        Err(e) => {
206            *decode_failures += 1;
207            warn!(
208                target: TRACE_TARGET,
209                op = "generate",
210                step,
211                error = %e,
212                "llama token piece decode failed; dropping it from the completion"
213            );
214        }
215    }
216}
217
218fn run_generation(
219    model: &LlamaModel,
220    backend: &LlamaBackend,
221    prompt: &str,
222    max_tokens: u32,
223    temperature: f32,
224) -> Result<(String, u32)> {
225    let ctx_size = NonZeroU32::new(2048).expect("non-zero");
226    let ctx_params = LlamaContextParams::default()
227        .with_n_ctx(Some(ctx_size))
228        .with_n_batch(512);
229    let mut ctx = model
230        .new_context(backend, ctx_params)
231        .context("creating llama context")?;
232
233    let tokens = model
234        .str_to_token(prompt, AddBos::Always)
235        .map_err(|e| anyhow!("tokenize prompt: {e:?}"))?;
236    if tokens.is_empty() {
237        bail!("prompt tokenised to zero tokens");
238    }
239
240    // A prompt plus its generation budget larger than the context
241    // window overflows the KV cache: later decode steps run past what
242    // this context was sized for and the output silently truncates.
243    // We deliberately don't trim the prompt here (that would mangle the
244    // operator's input) — instead we surface the condition so "why was
245    // my long chat cut off" is answerable from the logs.  Raise n_ctx
246    // for longer chats.
247    if exceeds_context_window(tokens.len(), max_tokens, ctx_size.get()) {
248        warn!(
249            target: TRACE_TARGET,
250            op = "generate",
251            prompt_tokens = tokens.len(),
252            max_tokens,
253            n_ctx = ctx_size.get(),
254            "prompt + max_tokens exceeds the context window; output may be \
255             truncated — raise n_ctx for longer chats"
256        );
257    }
258
259    let mut batch = LlamaBatch::new(2048, 1);
260    let last_index = tokens.len() as i32 - 1;
261    for (i, token) in (0_i32..).zip(tokens.iter().copied()) {
262        let is_last = i == last_index;
263        batch
264            .add(token, i, &[0], is_last)
265            .map_err(|e| anyhow!("batch add: {e:?}"))?;
266    }
267    ctx.decode(&mut batch).context("decoding prompt")?;
268
269    let mut sampler = LlamaSampler::chain_simple(if temperature <= 0.0 {
270        vec![LlamaSampler::greedy()]
271    } else {
272        vec![
273            LlamaSampler::temp(temperature),
274            LlamaSampler::dist(/* seed */ 1234),
275        ]
276    });
277
278    let mut out = String::new();
279    let mut decode_failures: u32 = 0;
280    let mut cursor = batch.n_tokens();
281    #[allow(clippy::explicit_counter_loop)]
282    for step in 0..max_tokens {
283        let new_token = sampler.sample(&ctx, batch.n_tokens() - 1);
284        sampler.accept(new_token);
285        if model.is_eog_token(new_token) {
286            break;
287        }
288        let mut decoder = encoding_rs::UTF_8.new_decoder();
289        append_piece(
290            &mut out,
291            step as usize,
292            model.token_to_piece(new_token, &mut decoder, false, None),
293            &mut decode_failures,
294        );
295        batch.clear();
296        batch
297            .add(new_token, cursor, &[0], true)
298            .map_err(|e| anyhow!("batch add (token): {e:?}"))?;
299        cursor += 1;
300        ctx.decode(&mut batch).context("decoding token")?;
301    }
302    Ok((out, decode_failures))
303}
304
305/// Sentinel the studio's claim filter recognises as "any llama-cpp
306/// model is fine" — mirrors the `sd-cpp:*` wildcard the image engine
307/// advertises.  The model files arrive on the offer's `ModelSource`, so
308/// the worker doesn't have to enumerate model ids up front; this lets a
309/// freshly-installed worker claim llama jobs and download the GGUF on
310/// demand.
311const LLAMA_MODEL_WILDCARD: &str = "llama-cpp:*";
312
313fn is_gguf(path: &Path) -> bool {
314    path.extension()
315        .and_then(|s| s.to_str())
316        .map(|e| e.eq_ignore_ascii_case("gguf"))
317        .unwrap_or(false)
318}
319
320/// Pick the GGUF to load from a set of downloaded model files: prefer
321/// the explicit `Model`-role file, else the first `.gguf`.  Pure so the
322/// selection contract is unit-tested without a download.
323fn pick_gguf(files: &[(ModelFileRole, PathBuf)]) -> Option<PathBuf> {
324    files
325        .iter()
326        .find(|(role, path)| matches!(role, ModelFileRole::Model) && is_gguf(path))
327        .or_else(|| files.iter().find(|(_, path)| is_gguf(path)))
328        .map(|(_, path)| path.clone())
329}
330
331/// Extract the LLM params from a task, rejecting any other kind with the
332/// `cannot serve <kind>` shape the studio's claim loop recognises.
333fn as_llm(task: Task, model: &str) -> Result<LlmParams> {
334    match task {
335        Task::Llm(p) => Ok(p),
336        other => {
337            warn!(
338                target: TRACE_TARGET,
339                op = "dispatch",
340                kind = other.kind().as_str(),
341                model,
342                "unsupported task kind"
343            );
344            Err(crate::engine::UnsupportedTask::new("llama", other.kind()).into())
345        }
346    }
347}
348
349impl LlamaEngine {
350    /// Download every file the studio listed on the offer into
351    /// `<root>/llm/`, returning the resolved (role, path) pairs.  Cached
352    /// files are reused; a truncated download is rejected by the shared
353    /// downloader rather than cached as a corrupt model.
354    #[cfg_attr(coverage_nightly, coverage(off))]
355    fn ensure_model_files(&self, source: &ModelSource) -> Result<Vec<(ModelFileRole, PathBuf)>> {
356        let dir = self.llm_dir();
357        let mut out = Vec::with_capacity(source.files.len());
358        for file in &source.files {
359            let local = crate::engine::download::ensure_file(&dir, file)?;
360            out.push((file.role, local));
361        }
362        Ok(out)
363    }
364
365    /// Load `path` (caching it) and run one chat completion, returning
366    /// `chat.completion`-shaped JSON.  Shared by the plain `dispatch`
367    /// (local model) and `dispatch_with_source` (downloaded model) paths.
368    fn run_llm(&self, model: &str, path: &Path, llm: LlmParams) -> Result<TaskResult> {
369        let loaded = self.load_or_get(model, path)?;
370        let prompt = render_prompt(&llm.messages);
371        debug!(
372            target: TRACE_TARGET,
373            op = "dispatch",
374            kind = "llm",
375            model,
376            max_tokens = llm.max_tokens,
377            temperature = llm.temperature,
378            messages = llm.messages.len(),
379            "starting generation"
380        );
381        let started = Instant::now();
382        let (content, decode_failures) = run_generation(
383            &loaded,
384            &self.backend,
385            &prompt,
386            llm.max_tokens.max(1),
387            llm.temperature.max(0.0),
388        )
389        .inspect_err(|e| {
390            warn!(
391                target: TRACE_TARGET,
392                op = "dispatch",
393                kind = "llm",
394                model,
395                elapsed_ms = started.elapsed().as_millis() as u64,
396                error = %e,
397                "generation failed"
398            );
399        })?;
400        let elapsed_ms = started.elapsed().as_millis() as u64;
401        info!(
402            target: TRACE_TARGET,
403            op = "dispatch",
404            kind = "llm",
405            model,
406            elapsed_ms,
407            completion_chars = content.len(),
408            decode_failures,
409            "generation complete"
410        );
411
412        let prompt_tokens = prompt.split_whitespace().count();
413        let completion_tokens = content.split_whitespace().count();
414        let json = serde_json::json!({
415            "object": "chat.completion",
416            "model": model,
417            "choices": [{
418                "index": 0,
419                "message": {
420                    "role": "assistant",
421                    "content": content.trim(),
422                },
423                "finish_reason": "stop",
424            }],
425            "usage": {
426                "prompt_tokens": prompt_tokens,
427                "completion_tokens": completion_tokens,
428                "total_tokens": prompt_tokens + completion_tokens,
429            },
430            "elapsed_ms": elapsed_ms,
431        });
432        Ok(TaskResult::Llm { json })
433    }
434}
435
436impl Engine for LlamaEngine {
437    fn name(&self) -> &'static str {
438        "llama"
439    }
440
441    fn capabilities(&self) -> EngineCapabilities {
442        // Advertise both any locally-present GGUF stems (pre-placed
443        // models) and the wildcard sentinel so the studio can hand this
444        // worker any llama-cpp model from its registry; the files come
445        // down on the offer's `ModelSource`.
446        let mut models: Vec<String> = self.list_models().into_iter().map(|(s, _)| s).collect();
447        models.push(LLAMA_MODEL_WILDCARD.to_string());
448        let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
449        map.insert(TaskKind::Llm, models);
450        EngineCapabilities {
451            supported_models_per_kind: map,
452        }
453    }
454
455    fn dispatch(&self, model: &str, task: Task) -> Result<TaskResult> {
456        let llm = as_llm(task, model)?;
457        let path = self.resolve_path(model).ok_or_else(|| {
458            warn!(
459                target: TRACE_TARGET,
460                op = "dispatch",
461                model,
462                models_root = %self.llm_dir().display(),
463                "model not found"
464            );
465            anyhow!(
466                "model `{model}` not found in {} and the offer carried no \
467                 modelSource to download it from",
468                self.llm_dir().display()
469            )
470        })?;
471        self.run_llm(model, &path, llm)
472    }
473
474    fn dispatch_with_source(
475        &self,
476        model: &str,
477        task: Task,
478        source: &ModelSource,
479    ) -> Result<TaskResult> {
480        let llm = as_llm(task, model)?;
481        // Prefer the studio-provided files (download on demand); fall
482        // back to a locally-present GGUF when the offer lists none.
483        let path = if source.files.is_empty() {
484            self.resolve_path(model).ok_or_else(|| {
485                anyhow!(
486                    "model `{model}` not found in {} and the offer's modelSource \
487                     listed no files to download",
488                    self.llm_dir().display()
489                )
490            })?
491        } else {
492            let resolved = self.ensure_model_files(source)?;
493            pick_gguf(&resolved)
494                .ok_or_else(|| anyhow!("llama modelSource for `{model}` contained no .gguf file"))?
495        };
496        self.run_llm(model, &path, llm)
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn exceeds_context_window_false_when_within_window() {
506        // 100 prompt + 50 budget = 150 <= 2048.
507        assert!(!exceeds_context_window(100, 50, 2048));
508    }
509
510    #[test]
511    fn exceeds_context_window_true_when_over_window() {
512        // 2000 prompt + 100 budget = 2100 > 2048.
513        assert!(exceeds_context_window(2000, 100, 2048));
514    }
515
516    #[test]
517    fn exceeds_context_window_false_at_exact_window() {
518        // Filling the window exactly is not yet an overflow.
519        assert!(!exceeds_context_window(1998, 50, 2048));
520    }
521
522    #[test]
523    fn exceeds_context_window_saturates_on_huge_budget() {
524        // A pathological max_tokens must not wrap to a small sum.
525        assert!(exceeds_context_window(1, u32::MAX, 2048));
526    }
527
528    #[test]
529    fn render_prompt_concatenates_messages_with_assistant_marker() {
530        let messages = vec![
531            ChatMessage {
532                role: "system".into(),
533                content: "be helpful".into(),
534            },
535            ChatMessage {
536                role: "user".into(),
537                content: "hi".into(),
538            },
539        ];
540        let rendered = render_prompt(&messages);
541        assert!(rendered.contains("<|system|>"));
542        assert!(rendered.contains("be helpful"));
543        assert!(rendered.contains("<|user|>"));
544        assert!(rendered.contains("hi"));
545        assert!(rendered.ends_with("<|assistant|>\n"));
546    }
547
548    // -----------------------------------------------------------------
549    // append_piece — accumulates a sampled token's decoded text into the
550    // running completion.  llama.cpp's `token_to_piece` occasionally
551    // fails to decode a token to UTF-8 text; a failed piece is dropped
552    // from the completion (preserving the existing behaviour) but
553    // counted and warn-logged, so a truncated completion can never pass
554    // for a complete one and the "generation complete" breadcrumb
555    // reports the real decode_failures.
556    // -----------------------------------------------------------------
557
558    #[test]
559    fn append_piece_concatenates_ok_pieces() {
560        let mut out = String::new();
561        let mut failures = 0u32;
562        append_piece(&mut out, 0, Ok::<_, &str>("hel".to_string()), &mut failures);
563        append_piece(&mut out, 1, Ok::<_, &str>("lo".to_string()), &mut failures);
564        assert_eq!(out, "hello");
565        assert_eq!(failures, 0);
566    }
567
568    #[test]
569    fn append_piece_drops_and_counts_failed_pieces() {
570        // A token whose text can't be decoded is dropped from the
571        // completion but counted, never silently lost — so a truncated
572        // completion can't pass for a complete one.
573        let mut out = String::new();
574        let mut failures = 0u32;
575        append_piece(
576            &mut out,
577            0,
578            Ok::<_, &str>("kept".to_string()),
579            &mut failures,
580        );
581        append_piece(&mut out, 1, Err("invalid utf-8"), &mut failures);
582        append_piece(
583            &mut out,
584            2,
585            Ok::<_, &str>(" tail".to_string()),
586            &mut failures,
587        );
588        assert_eq!(out, "kept tail");
589        assert_eq!(failures, 1);
590    }
591
592    #[test]
593    fn append_piece_warns_on_each_decode_failure() {
594        let logs = crate::test_support::capture(|| {
595            let mut out = String::new();
596            let mut failures = 0u32;
597            append_piece(&mut out, 7, Err("decode boom"), &mut failures);
598        });
599        assert!(
600            logs.contains("studio_worker::engine::llama"),
601            "expected llama target, got: {logs}"
602        );
603        assert!(logs.contains("WARN"), "expected WARN level, got: {logs}");
604        assert!(
605            logs.contains("decode boom"),
606            "expected the underlying error, got: {logs}"
607        );
608        assert!(
609            logs.contains("step=7"),
610            "expected the step index, got: {logs}"
611        );
612    }
613
614    /// Regression: `global_backend()` once used a bounded spin-wait for
615    /// the `BackendAlreadyInitialized` race and flaked on loaded CI
616    /// runners.  Hammer it from many threads — every call must succeed.
617    #[test]
618    fn global_backend_never_fails_under_contention() {
619        let handles: Vec<_> = (0..32)
620            .map(|_| std::thread::spawn(|| global_backend().map(|_| ())))
621            .collect();
622        for h in handles {
623            h.join()
624                .expect("thread panicked")
625                .expect("global_backend must never fail under contention");
626        }
627    }
628
629    #[test]
630    fn capabilities_advertise_wildcard_even_with_no_local_models() {
631        // A fresh worker has no local GGUFs but must still advertise the
632        // wildcard so the studio can hand it a llama job (files arrive on
633        // the offer's modelSource).
634        let tmp = tempfile::tempdir().unwrap();
635        let engine = LlamaEngine::new(tmp.path().to_path_buf()).expect("init backend");
636        let caps = engine.capabilities();
637        let models = &caps.supported_models_per_kind[&TaskKind::Llm];
638        assert_eq!(models, &vec![LLAMA_MODEL_WILDCARD.to_string()]);
639        assert!(caps.supports(TaskKind::Llm, LLAMA_MODEL_WILDCARD));
640    }
641
642    #[test]
643    fn capabilities_picks_up_gguf_files_and_keeps_wildcard() {
644        let tmp = tempfile::tempdir().unwrap();
645        let llm_dir = tmp.path().join("llm");
646        std::fs::create_dir_all(&llm_dir).unwrap();
647        // Just touch a file; we never load it.
648        std::fs::write(llm_dir.join("smollm-135m-q8.gguf"), b"not-real").unwrap();
649        std::fs::write(llm_dir.join("ignored.txt"), b"x").unwrap();
650        let engine = LlamaEngine::new(tmp.path().to_path_buf()).expect("init backend");
651        let caps = engine.capabilities();
652        let models = &caps.supported_models_per_kind[&TaskKind::Llm];
653        assert_eq!(
654            models,
655            &vec![
656                "smollm-135m-q8".to_string(),
657                LLAMA_MODEL_WILDCARD.to_string()
658            ]
659        );
660    }
661
662    #[test]
663    fn is_gguf_matches_extension_case_insensitively() {
664        assert!(is_gguf(Path::new("/m/model.gguf")));
665        assert!(is_gguf(Path::new("/m/model.GGUF")));
666        assert!(!is_gguf(Path::new("/m/model.safetensors")));
667        assert!(!is_gguf(Path::new("/m/model")));
668    }
669
670    #[test]
671    fn pick_gguf_prefers_model_role_then_first_gguf() {
672        // Model-role gguf wins even when listed after another gguf.
673        let files = vec![
674            (ModelFileRole::TextEncoder, PathBuf::from("/m/clip.gguf")),
675            (ModelFileRole::Model, PathBuf::from("/m/weights.gguf")),
676        ];
677        assert_eq!(pick_gguf(&files), Some(PathBuf::from("/m/weights.gguf")));
678        // No Model role: fall back to the first gguf.
679        let files = vec![
680            (ModelFileRole::Vae, PathBuf::from("/m/vae.safetensors")),
681            (ModelFileRole::TextEncoder, PathBuf::from("/m/first.gguf")),
682            (ModelFileRole::Lora, PathBuf::from("/m/second.gguf")),
683        ];
684        assert_eq!(pick_gguf(&files), Some(PathBuf::from("/m/first.gguf")));
685        // Nothing gguf at all.
686        let files = vec![(ModelFileRole::Vae, PathBuf::from("/m/vae.safetensors"))];
687        assert_eq!(pick_gguf(&files), None);
688    }
689
690    #[test]
691    fn as_llm_extracts_llm_params_and_rejects_other_kinds() {
692        let llm = Task::Llm(LlmParams {
693            messages: vec![ChatMessage {
694                role: "user".into(),
695                content: "hi".into(),
696            }],
697            max_tokens: 8,
698            temperature: 0.1,
699            ..Default::default()
700        });
701        assert!(as_llm(llm, "m").is_ok());
702        let image = Task::Image(ImageParams {
703            prompt: "x".into(),
704            ..Default::default()
705        });
706        let err = as_llm(image, "m").unwrap_err().to_string();
707        assert!(err.contains("cannot serve image"), "got: {err}");
708    }
709
710    #[test]
711    fn dispatch_returns_error_when_model_missing() {
712        let tmp = tempfile::tempdir().unwrap();
713        let engine = LlamaEngine::new(tmp.path().to_path_buf()).expect("init backend");
714        let task = Task::Llm(LlmParams {
715            messages: vec![ChatMessage {
716                role: "user".into(),
717                content: "hi".into(),
718            }],
719            max_tokens: 1,
720            temperature: 0.0,
721            ..Default::default()
722        });
723        let err = engine.dispatch("no-such-model", task).unwrap_err();
724        assert!(err.to_string().contains("not found"));
725    }
726
727    #[test]
728    fn dispatch_rejects_non_llm_tasks() {
729        let tmp = tempfile::tempdir().unwrap();
730        let engine = LlamaEngine::new(tmp.path().to_path_buf()).expect("init backend");
731        let task = Task::Image(ImageParams {
732            prompt: "x".into(),
733            width: 64,
734            height: 64,
735            steps: 1,
736            seed: None,
737            ext: "webp".into(),
738            ..Default::default()
739        });
740        let err = engine.dispatch("anything", task).unwrap_err();
741        assert!(err.to_string().contains("cannot serve image"));
742    }
743}