1use 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
26const 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
36static GLOBAL_BACKEND: std::sync::OnceLock<Arc<LlamaBackend>> = std::sync::OnceLock::new();
40
41static 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 if let Some(b) = GLOBAL_BACKEND.get() {
57 return Ok(b.clone());
58 }
59 let backend = LlamaBackend::init().map_err(|e| match e {
60 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, ¶ms)
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 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
179fn 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
187fn 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 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(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
305const 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
320fn 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
331fn 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 #[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 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 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 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 assert!(!exceeds_context_window(100, 50, 2048));
508 }
509
510 #[test]
511 fn exceeds_context_window_true_when_over_window() {
512 assert!(exceeds_context_window(2000, 100, 2048));
514 }
515
516 #[test]
517 fn exceeds_context_window_false_at_exact_window() {
518 assert!(!exceeds_context_window(1998, 50, 2048));
520 }
521
522 #[test]
523 fn exceeds_context_window_saturates_on_huge_budget() {
524 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 #[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 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 #[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 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 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 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 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 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}