1use 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
32const TRACE_TARGET: &str = "studio_worker::engine::llama";
35
36pub struct LlamaEngine {
37 backend: Arc<LlamaBackend>,
38 models_root: PathBuf,
39}
40
41static GLOBAL_BACKEND: std::sync::OnceLock<Arc<LlamaBackend>> = std::sync::OnceLock::new();
45
46static 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 if let Some(b) = GLOBAL_BACKEND.get() {
62 return Ok(b.clone());
63 }
64 let backend = LlamaBackend::init().map_err(|e| match e {
65 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 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
149fn 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
180fn gpu_layers() -> u32 {
182 if cfg!(feature = "cuda") {
183 999
184 } else {
185 0
186 }
187}
188
189const 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, ¶ms)
196 .with_context(|| format!("loading model {id} from {}", path.display()))
197}
198
199fn 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
213fn 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
241pub 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, ¶ms)?;
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 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
369pub 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#[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#[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
443const 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
458fn 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
469fn 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 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 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 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 #[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 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 #[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 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 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 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 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 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}