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