use crate::backend::BackendFactory;
use crate::infer::{InferBackend, InferRequest, InferResult};
use async_trait::async_trait;
use llama_cpp_2::context::params::LlamaContextParams;
use llama_cpp_2::llama_backend::LlamaBackend;
use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::model::{AddBos, LlamaChatMessage, LlamaModel};
use llama_cpp_2::mtmd::{MtmdBitmap, MtmdContext, MtmdContextParams, MtmdInputText};
use llama_cpp_2::sampling::LlamaSampler;
use std::num::NonZeroU32;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
const DEFAULT_CONTEXT: u32 = 4096;
pub fn shared_backend() -> anyhow::Result<&'static LlamaBackend> {
static BACKEND: OnceLock<Result<LlamaBackend, String>> = OnceLock::new();
BACKEND
.get_or_init(|| LlamaBackend::init().map_err(|e| e.to_string()))
.as_ref()
.map_err(|e| anyhow::anyhow!("llama.cpp backend failed to initialise: {e}"))
}
pub struct LlamaCppBackend {
mmproj: Option<std::path::PathBuf>,
model: Arc<LlamaModel>,
name: String,
context_size: u32,
}
impl LlamaCppBackend {
pub fn load(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let path = path.as_ref();
if !path.exists() {
anyhow::bail!("no model file at {}", path.display());
}
let model =
LlamaModel::load_from_file(shared_backend()?, path, &LlamaModelParams::default())
.map_err(|e| anyhow::anyhow!("loading {}: {e}", path.display()))?;
let mmproj = find_mmproj(path);
if let Some(found) = &mmproj {
eprintln!("llamacpp: using multimodal projector {}", found.display());
}
Ok(Self {
mmproj,
model: Arc::new(model),
name: path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
context_size: DEFAULT_CONTEXT,
})
}
pub fn with_context_size(mut self, tokens: u32) -> Self {
self.context_size = tokens;
self
}
pub fn with_mmproj(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.mmproj = Some(path.into());
self
}
pub fn has_projector(&self) -> bool {
self.mmproj.is_some()
}
}
fn find_mmproj(model: &Path) -> Option<std::path::PathBuf> {
let dir = model.parent()?;
let mut found: Vec<_> = std::fs::read_dir(dir)
.ok()?
.flatten()
.map(|entry| entry.path())
.filter(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
name.starts_with("mmproj") && name.ends_with(".gguf")
})
.collect();
found.sort();
found.into_iter().next()
}
fn decode_rgb(bytes: &[u8]) -> anyhow::Result<(u32, u32, Vec<u8>)> {
let decoded = image::load_from_memory(bytes)
.map_err(|e| anyhow::anyhow!("decoding an image for the vision model: {e}"))?;
let rgb = decoded.to_rgb8();
Ok((rgb.width(), rgb.height(), rgb.into_raw()))
}
#[allow(clippy::too_many_arguments)]
fn generate(
model: &LlamaModel,
mmproj: Option<&Path>,
context_size: u32,
prompt: &str,
images: &[Vec<u8>],
max_tokens: u32,
mut emit: impl FnMut(&str) -> bool,
) -> anyhow::Result<InferResult> {
let ctx_size = NonZeroU32::new(context_size.max(1)).expect("max(1) is non-zero");
let mut ctx = model
.new_context(
shared_backend()?,
LlamaContextParams::default().with_n_ctx(Some(ctx_size)),
)
.map_err(|e| anyhow::anyhow!("creating llama.cpp context: {e}"))?;
let prompt = match model.chat_template(None) {
Ok(template) => {
let message = LlamaChatMessage::new("user".to_string(), prompt.to_string())
.map_err(|e| anyhow::anyhow!("building chat message: {e}"))?;
model
.apply_chat_template(&template, &[message], true)
.map_err(|e| anyhow::anyhow!("applying the model's chat template: {e}"))?
}
Err(_) => prompt.to_string(),
};
let mut batch = LlamaBatch::new(context_size as usize, 1);
let (tokens_in, mut pos) = if images.is_empty() {
let tokens = model
.str_to_token(&prompt, AddBos::Always)
.map_err(|e| anyhow::anyhow!("tokenizing prompt: {e}"))?;
let tokens_in = tokens.len() as u32;
if tokens_in >= context_size {
anyhow::bail!(
"prompt is {tokens_in} tokens but the context window is {context_size}; \
raise the context size or shorten the prompt"
);
}
let last = tokens.len().saturating_sub(1);
for (i, token) in tokens.iter().enumerate() {
batch.add(*token, i as i32, &[0], i == last)?;
}
ctx.decode(&mut batch)
.map_err(|e| anyhow::anyhow!("decoding prompt: {e}"))?;
(tokens_in, last as i32 + 1)
} else {
let mmproj = mmproj.ok_or_else(|| {
anyhow::anyhow!(
"this job supplied images, but no multimodal projector was found \
beside the model. A vision model needs an `mmproj-*.gguf` in the \
same directory as its weights."
)
})?;
let mtmd = MtmdContext::init_from_file(
&mmproj.to_string_lossy(),
model,
&MtmdContextParams::default(),
)
.map_err(|e| {
anyhow::anyhow!("loading the multimodal projector {}: {e}", mmproj.display())
})?;
let marker = llama_cpp_2::mtmd::mtmd_default_marker();
let mut bitmaps = Vec::with_capacity(images.len());
for bytes in images {
let (w, h, rgb) = decode_rgb(bytes)?;
bitmaps.push(
MtmdBitmap::from_image_data(w, h, &rgb)
.map_err(|e| anyhow::anyhow!("preparing an image for the model: {e}"))?,
);
}
let refs: Vec<&MtmdBitmap> = bitmaps.iter().collect();
let text = format!("{}{prompt}", marker.repeat(images.len()));
let chunks = mtmd
.tokenize(
MtmdInputText {
text,
add_special: true,
parse_special: true,
},
&refs,
)
.map_err(|e| anyhow::anyhow!("tokenizing the prompt with images: {e}"))?;
let n_past = chunks
.eval_chunks(&mtmd, &ctx, 0, 0, context_size as i32, true)
.map_err(|e| anyhow::anyhow!("evaluating the prompt with images: {e}"))?;
(n_past as u32, n_past)
};
let mut sampler = LlamaSampler::chain_simple([
LlamaSampler::penalties(64, 1.1, 0.0, 0.0),
LlamaSampler::greedy(),
]);
let mut text = String::new();
let mut tokens_out = 0u32;
let mut decoder = encoding_rs::UTF_8.new_decoder();
while tokens_out < max_tokens {
let token = sampler.sample(&ctx, -1);
sampler.accept(token);
if model.is_eog_token(token) {
break;
}
let piece = model
.token_to_piece(token, &mut decoder, false, None)
.map_err(|e| anyhow::anyhow!("decoding generated token: {e}"))?;
tokens_out += 1;
text.push_str(&piece);
if !emit(&piece) {
break;
}
batch.clear();
batch.add(token, pos, &[0], true)?;
pos += 1;
ctx.decode(&mut batch)
.map_err(|e| anyhow::anyhow!("decoding token: {e}"))?;
}
Ok(InferResult {
text,
tokens_in,
tokens_out,
})
}
#[async_trait]
impl InferBackend for LlamaCppBackend {
async fn infer(
&self,
req: InferRequest<'_>,
on_token: &mut (dyn for<'t> FnMut(&'t str) -> bool + Send),
) -> anyhow::Result<InferResult> {
if !req.images.is_empty() && self.mmproj.is_none() {
anyhow::bail!(
"this job supplied images, but no multimodal projector was found \
beside {}. A vision model needs an `mmproj-*.gguf` in the same \
directory as its weights.",
self.name
);
}
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let stop = Arc::new(AtomicBool::new(false));
let handle = {
let (model, stop, prompt) = (self.model.clone(), stop.clone(), req.prompt.to_string());
let (context_size, max_tokens) = (self.context_size, req.max_tokens);
let mmproj = self.mmproj.clone();
let images: Vec<Vec<u8>> = req.images.to_vec();
tokio::task::spawn_blocking(move || {
generate(
&model,
mmproj.as_deref(),
context_size,
&prompt,
&images,
max_tokens,
|piece| tx.send(piece.to_string()).is_ok() && !stop.load(Ordering::Relaxed),
)
})
};
tokio::pin!(handle);
loop {
tokio::select! {
biased;
Some(piece) = rx.recv() => {
if !on_token(&piece) {
stop.store(true, Ordering::Relaxed);
}
}
joined = &mut handle => {
while let Ok(piece) = rx.try_recv() {
on_token(&piece);
}
return joined.map_err(|e| anyhow::anyhow!("inference thread failed: {e}"))?;
}
}
}
}
fn model_name(&self) -> String {
self.name.clone()
}
fn supports_images(&self) -> bool {
self.mmproj.is_some()
}
}
pub struct LlamaCppFactory;
impl BackendFactory for LlamaCppFactory {
fn provider(&self) -> &'static str {
"llamacpp"
}
fn describe(&self) -> &'static str {
"an embedded llama.cpp; target is a path to a .gguf model file"
}
fn build(&self, target: &str) -> anyhow::Result<Arc<dyn InferBackend>> {
if target.is_empty() {
anyhow::bail!("a path to a .gguf model file is required");
}
Ok(Arc::new(LlamaCppBackend::load(target)?))
}
}