use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::gguf::GgufFile;
use crate::kv_cache::KvCacheConfig;
#[cfg(feature = "mmap")]
use crate::manifest::ManifestFiles;
use crate::manifest::{InferenceType, Manifest};
use crate::model::audio_encoder::AudioEncoderWeights;
use crate::model::vision_encoder::VisionEncoderWeights;
use crate::model::{self, Model};
use crate::session::{CeraError, ModalityCapabilities, Session, SessionConfig};
use crate::tokenizer::BpeTokenizer;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BackendPreference {
#[default]
Auto,
Cpu,
Gpu,
Metal,
}
impl BackendPreference {
pub fn parse_str(s: &str) -> Result<Self, CeraError> {
match s.to_ascii_lowercase().as_str() {
"auto" | "" => Ok(Self::Auto),
"cpu" => Ok(Self::Cpu),
"gpu" | "wgpu" => Ok(Self::Gpu),
"metal" => Ok(Self::Metal),
other => Err(CeraError::Backend(format!(
"unknown backend preference `{other}` (use auto, cpu, gpu, or metal)"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub context_size: usize,
pub backend: BackendPreference,
#[cfg(feature = "remote")]
pub bundle_repo: Option<crate::bundle::BundleRepo>,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
context_size: 4096,
backend: BackendPreference::Auto,
#[cfg(feature = "remote")]
bundle_repo: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ModelFiles {
pub model: PathBuf,
pub multimodal_projector: Option<PathBuf>,
pub audio_decoder: Option<PathBuf>,
pub audio_tokenizer: Option<PathBuf>,
pub extras: std::collections::HashMap<String, PathBuf>,
pub inference_type: Option<InferenceType>,
pub chat_template: Option<String>,
}
impl ModelFiles {
pub fn text(path: impl Into<PathBuf>) -> Self {
Self {
model: path.into(),
multimodal_projector: None,
audio_decoder: None,
audio_tokenizer: None,
extras: std::collections::HashMap::new(),
inference_type: Some(InferenceType::LlamaCppTextToText),
chat_template: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ModelMetadata {
pub architecture: String,
pub max_seq_len: u32,
pub vocab_size: u32,
pub has_chat_template: bool,
pub quantization: String,
pub add_bos_token: bool,
}
pub struct CeraEngine {
manifest: Manifest,
model: Arc<dyn Model>,
tokenizer: Arc<BpeTokenizer>,
metadata: ModelMetadata,
config: EngineConfig,
audio_encoder: Option<Arc<AudioEncoderWeights>>,
vision_encoder_gguf: Option<Arc<GgufFile>>,
vision_encoder: Option<Arc<VisionEncoderWeights>>,
gpu_vision_encoder: Option<Arc<dyn crate::model::vision_encoder_gpu::VisionGpuEncode>>,
}
impl CeraEngine {
#[cfg(feature = "mmap")]
pub fn from_path<P: AsRef<Path>>(path: P, cfg: EngineConfig) -> Result<Self, CeraError> {
let path = path.as_ref();
if path.is_dir() {
let manifest_path = find_single_manifest(path)?;
Self::from_manifest_file(&manifest_path, cfg)
} else if has_extension(path, "json") {
Self::from_manifest_file(path, cfg)
} else if has_extension(path, "gguf") {
let detected = auto_detect_inference_type(path)?;
match detected {
InferenceType::LlamaCppTextToText | InferenceType::LlamaCppLfm2AudioV1 => {
let manifest = Manifest::synthetic_text(path);
Self::from_manifest(manifest, cfg)
}
InferenceType::LlamaCppImageToText => Err(CeraError::Backend(format!(
"bare-GGUF VL load is not supported (file `{}`); load via a \
`.json` manifest, a directory containing one, \
`from_files`, or `from_bundle_id` so the vision mmproj \
can be attached",
path.display()
))),
InferenceType::Unknown(s) => Err(CeraError::UnsupportedInferenceType(s)),
}
} else {
Err(CeraError::Backend(format!(
"don't know how to load `{}` — expected a .gguf file, a .json manifest, or a directory containing one",
path.display()
)))
}
}
pub fn from_bytes(bytes: impl Into<Arc<[u8]>>, cfg: EngineConfig) -> Result<Self, CeraError> {
let arc_bytes: Arc<[u8]> = bytes.into();
let gguf = GgufFile::from_bytes(arc_bytes)
.map_err(|e| CeraError::Backend(format!("parsing GGUF bytes: {e}")))?;
let manifest = Manifest::synthetic_text(Path::new("<bytes>"));
Self::from_gguf(gguf, manifest, cfg, None)
}
pub fn from_reader<R: Read>(reader: R, cfg: EngineConfig) -> Result<Self, CeraError> {
let gguf = GgufFile::from_reader(reader)
.map_err(|e| CeraError::Backend(format!("reading GGUF stream: {e}")))?;
let manifest = Manifest::synthetic_text(Path::new("<reader>"));
Self::from_gguf(gguf, manifest, cfg, None)
}
#[cfg(feature = "mmap")]
pub fn from_files(files: ModelFiles, cfg: EngineConfig) -> Result<Self, CeraError> {
let mut manifest = synthesize_manifest_from_files(&files)?;
resolve_all_manifest_files(&mut manifest, files.model.parent(), &cfg)?;
Self::from_manifest_with_primary(manifest, files.model.as_path(), cfg)
}
#[cfg(all(feature = "remote", feature = "mmap"))]
pub fn from_bundle_id(
bundle_id: &str,
quant: &str,
cfg: EngineConfig,
) -> Result<Self, CeraError> {
let repo = cfg.bundle_repo.as_ref().ok_or_else(|| {
CeraError::Backend(
"`CeraEngine::from_bundle_id` requires `EngineConfig::bundle_repo` to be set — \
construct a `BundleRepo` rooted at your desired store directory and assign it \
before calling this constructor."
.to_string(),
)
})?;
let manifest_url = crate::bundle::leap_bundles_manifest_url(bundle_id, quant)?;
let manifest_path = repo.resolve_url(&manifest_url, None)?;
Self::from_manifest_file(&manifest_path, cfg)
}
fn from_gguf(
gguf: GgufFile,
manifest: Manifest,
cfg: EngineConfig,
path: Option<&Path>,
) -> Result<Self, CeraError> {
check_inference_type_supported(&manifest.inference_type)?;
let tokenizer = BpeTokenizer::from_gguf(&gguf)
.map_err(|e| CeraError::Backend(format!("loading tokenizer: {e}")))?;
let add_bos_token = gguf
.get_bool("tokenizer.ggml.add_bos_token")
.unwrap_or(false);
let quantization = gguf
.get_u32("general.file_type")
.map(ftype_label)
.unwrap_or_else(|| "unknown".to_string());
let model: Arc<dyn Model> = Arc::from(load_text_model(gguf, path, &cfg)?);
let metadata = build_metadata(
model.as_ref(),
&tokenizer,
&manifest,
add_bos_token,
quantization,
);
let audio_encoder = if path.is_some() {
try_load_audio_encoder(&manifest)
} else {
None
};
let vision_encoder_gguf = if path.is_some() {
try_load_vision_encoder_gguf(&manifest)
} else {
None
};
let vl_mmproj_path = manifest.files.multimodal_projector.as_deref();
let vision_encoder = vision_encoder_gguf
.as_ref()
.and_then(|g| try_parse_vision_encoder(g, vl_mmproj_path));
let gpu_vision_encoder = vision_encoder.as_ref().and_then(|w| {
crate::model::vision_encoder_gpu::build_gpu_vision_encoder(w, cfg.backend)
});
Ok(Self {
manifest,
model,
tokenizer: Arc::new(tokenizer),
metadata,
config: cfg,
audio_encoder,
vision_encoder_gguf,
vision_encoder,
gpu_vision_encoder,
})
}
#[cfg(feature = "mmap")]
fn from_manifest_file(path: &Path, cfg: EngineConfig) -> Result<Self, CeraError> {
let mut manifest = Manifest::from_file(path).map_err(|e| {
CeraError::Backend(format!("parsing manifest `{}`: {e}", path.display()))
})?;
resolve_all_manifest_files(&mut manifest, path.parent(), &cfg)?;
let primary = PathBuf::from(&manifest.files.model);
Self::from_manifest_with_primary(manifest, &primary, cfg)
}
#[cfg(feature = "mmap")]
fn from_manifest_with_primary(
manifest: Manifest,
primary: &Path,
cfg: EngineConfig,
) -> Result<Self, CeraError> {
check_inference_type_supported(&manifest.inference_type)?;
let gguf = GgufFile::open(primary)
.map_err(|e| CeraError::Backend(format!("opening `{}`: {e}", primary.display())))?;
Self::from_gguf(gguf, manifest, cfg, Some(primary))
}
#[cfg(feature = "mmap")]
fn from_manifest(mut manifest: Manifest, cfg: EngineConfig) -> Result<Self, CeraError> {
resolve_all_manifest_files(&mut manifest, None, &cfg)?;
let primary = PathBuf::from(&manifest.files.model);
Self::from_manifest_with_primary(manifest, &primary, cfg)
}
pub fn new_session(&self, cfg: SessionConfig) -> Session {
let mut session = Session::new(
Arc::clone(&self.model),
Arc::clone(&self.tokenizer),
self.capabilities(),
cfg,
);
if let Some(encoder) = &self.audio_encoder {
session.attach_audio_encoder(Arc::clone(encoder));
}
if let Some(encoder) = &self.vision_encoder {
session.attach_vision_encoder(Arc::clone(encoder));
}
if let Some(gpu) = &self.gpu_vision_encoder {
session.attach_gpu_vision_encoder(Arc::clone(gpu));
}
session
}
pub const AUDIO_MARKER_CANDIDATES: [&'static str; 4] = [
"<|reserved_4|>",
"<|reserved_5|>",
"<|reserved_6|>",
"<|reserved_7|>",
];
pub fn split_tokens_at_marker(
tokens: &[u32],
marker_id: u32,
marker_name: &str,
) -> Result<usize, CeraError> {
let mut found: Option<usize> = None;
let mut count: usize = 0;
for (i, &t) in tokens.iter().enumerate() {
if t == marker_id {
count += 1;
if found.is_none() {
found = Some(i);
}
}
}
match (count, found) {
(1, Some(idx)) => Ok(idx),
(0, _) => Err(CeraError::Backend(format!(
"audio marker token `{marker_name}` (id {marker_id}) not found in rendered \
chat-template tokens — the template may have stripped or escaped the placeholder"
))),
(n, _) => Err(CeraError::Backend(format!(
"audio marker token `{marker_name}` (id {marker_id}) appears {n} times in \
rendered tokens; expected exactly one insertion point (check that prompt/system \
text does not contain a literal `{marker_name}`)"
))),
}
}
pub fn transcribe(&self, pcm: &[f32], sample_rate: u32) -> Result<String, CeraError> {
use crate::session::{FinishReason, GenerateOpts, ModalitySink};
use crate::tokenizer::{ChatMessage, apply_chat_template};
let tok = self.tokenizer();
let (marker_id, marker_name) = Self::AUDIO_MARKER_CANDIDATES
.into_iter()
.find_map(|name| tok.special_token_id(name).map(|id| (id, name)))
.ok_or_else(|| {
CeraError::Backend(
"no audio marker special token (<|reserved_4|>..7) in tokenizer".to_string(),
)
})?;
let messages = [
ChatMessage {
role: "system".to_string(),
content: "Perform ASR.".to_string(),
},
ChatMessage {
role: "user".to_string(),
content: marker_name.to_string(),
},
];
let formatted = apply_chat_template(tok, &messages, true)
.map_err(|e| CeraError::Backend(format!("chat template render failed: {e}")))?;
let toks = tok.encode(&formatted);
let split = Self::split_tokens_at_marker(&toks, marker_id, marker_name)?;
let mut session = self.new_session(SessionConfig::default());
if split > 0 {
session.append_tokens(&toks[..split])?;
}
session.append_audio(pcm, sample_rate)?;
if split + 1 < toks.len() {
session.append_tokens(&toks[split + 1..])?;
}
struct CollectSink {
tokens: Vec<u32>,
}
impl ModalitySink for CollectSink {
fn on_text_tokens(&mut self, tokens: &[u32]) {
self.tokens.extend_from_slice(tokens);
}
fn on_done(&mut self, _reason: FinishReason) {}
}
let mut sink = CollectSink { tokens: Vec::new() };
let opts = GenerateOpts {
temperature: 0.0,
..GenerateOpts::default()
};
session.generate(&opts, &mut sink)?;
Ok(tok.decode(&sink.tokens).trim().to_string())
}
pub fn audio_encoder(&self) -> Option<&Arc<AudioEncoderWeights>> {
self.audio_encoder.as_ref()
}
pub fn vision_encoder(&self) -> Option<&Arc<VisionEncoderWeights>> {
self.vision_encoder.as_ref()
}
pub fn has_gpu_vision_encoder(&self) -> bool {
self.gpu_vision_encoder.is_some()
}
#[doc(hidden)]
pub fn vision_encoder_gguf(&self) -> Option<&Arc<GgufFile>> {
self.vision_encoder_gguf.as_ref()
}
pub fn capabilities(&self) -> ModalityCapabilities {
ModalityCapabilities::from_inference_type(&self.manifest.inference_type)
}
pub fn model(&self) -> &dyn Model {
self.model.as_ref()
}
pub fn model_arc(&self) -> Arc<dyn Model> {
Arc::clone(&self.model)
}
pub fn tokenizer(&self) -> &BpeTokenizer {
self.tokenizer.as_ref()
}
pub fn tokenizer_arc(&self) -> Arc<BpeTokenizer> {
Arc::clone(&self.tokenizer)
}
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
pub fn metadata(&self) -> &ModelMetadata {
&self.metadata
}
pub fn config(&self) -> &EngineConfig {
&self.config
}
pub fn configure_cache(&self, cfg: KvCacheConfig) {
self.model.configure_cache(cfg);
}
}
#[cfg(feature = "mmap")]
fn has_extension(p: &Path, ext: &str) -> bool {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case(ext))
}
#[cfg(feature = "mmap")]
fn find_single_manifest(dir: &Path) -> Result<PathBuf, CeraError> {
let entries = std::fs::read_dir(dir)
.map_err(|e| CeraError::Backend(format!("reading directory `{}`: {e}", dir.display())))?;
let mut jsons: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry =
entry.map_err(|e| CeraError::Backend(format!("reading directory entry: {e}")))?;
let path = entry.path();
if path.is_file() && has_extension(&path, "json") {
jsons.push(path);
}
}
match jsons.len() {
0 => Err(CeraError::Backend(format!(
"no .json manifest in directory `{}`",
dir.display()
))),
1 => Ok(jsons.into_iter().next().unwrap()),
n => {
jsons.sort();
let names: Vec<String> = jsons
.iter()
.filter_map(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()))
.collect();
Err(CeraError::Backend(format!(
"{n} .json manifests in directory `{}` (expected exactly one): {}",
dir.display(),
names.join(", ")
)))
}
}
}
#[cfg(feature = "mmap")]
fn resolve_all_manifest_files(
manifest: &mut Manifest,
manifest_dir: Option<&Path>,
cfg: &EngineConfig,
) -> Result<(), CeraError> {
manifest.files.model = resolve_url_or_path(&manifest.files.model, manifest_dir, cfg)?
.to_string_lossy()
.into_owned();
for slot in [
&mut manifest.files.multimodal_projector,
&mut manifest.files.audio_decoder,
&mut manifest.files.audio_tokenizer,
] {
if let Some(s) = slot.as_ref() {
let resolved = resolve_url_or_path(s, manifest_dir, cfg)?;
*slot = Some(resolved.to_string_lossy().into_owned());
}
}
for value in manifest.files.extras.values_mut() {
*value = resolve_url_or_path(value, manifest_dir, cfg)?
.to_string_lossy()
.into_owned();
}
Ok(())
}
#[cfg(feature = "mmap")]
fn resolve_url_or_path(
value: &str,
base_dir: Option<&Path>,
cfg: &EngineConfig,
) -> Result<PathBuf, CeraError> {
if is_remote_url(value) {
#[cfg(feature = "remote")]
{
if let Some(repo) = cfg.bundle_repo.as_ref() {
return repo.resolve_url(value, None);
}
return Err(CeraError::Backend(format!(
"manifest references remote URL `{value}` — set `EngineConfig::bundle_repo` \
to a `BundleRepo` rooted at your desired store directory, or pre-download \
the bundle and pass a local file path."
)));
}
#[cfg(not(feature = "remote"))]
{
let _ = cfg;
return Err(CeraError::Backend(format!(
"manifest references remote URL `{value}` — rebuild cera with the `remote` \
feature + set `EngineConfig::bundle_repo`, or pre-download the bundle \
and pass a local file path."
)));
}
}
if let Some(rest) = strip_file_scheme(value) {
return Err(CeraError::Backend(format!(
"manifest references `file://` URI `{value}` — cera doesn't parse file URIs yet; \
pass the local path directly (e.g. `{rest}`)."
)));
}
let p = Path::new(value);
if p.is_absolute() {
Ok(p.to_path_buf())
} else if let Some(base) = base_dir {
Ok(base.join(p))
} else {
Ok(p.to_path_buf())
}
}
#[cfg(feature = "mmap")]
fn is_remote_url(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
#[cfg(feature = "mmap")]
fn strip_file_scheme(s: &str) -> Option<&str> {
let lower = s.to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("file://") {
let offset = s.len() - rest.len();
Some(&s[offset..])
} else {
None
}
}
#[cfg(feature = "mmap")]
fn synthesize_manifest_from_files(files: &ModelFiles) -> Result<Manifest, CeraError> {
let inference_type = match files.inference_type.clone() {
Some(it) => it,
None => auto_detect_inference_type(&files.model)?,
};
let model_str = files.model.to_string_lossy().into_owned();
let mmproj = files
.multimodal_projector
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
let audio_decoder = files
.audio_decoder
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
let audio_tokenizer = files
.audio_tokenizer
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
let mut extras_str = std::collections::HashMap::with_capacity(files.extras.len());
for (k, v) in &files.extras {
extras_str.insert(k.clone(), v.to_string_lossy().into_owned());
}
let mut load_params = serde_json::Map::new();
load_params.insert("model".into(), serde_json::Value::String(model_str.clone()));
if let Some(v) = &mmproj {
load_params.insert(
"multimodal_projector".into(),
serde_json::Value::String(v.clone()),
);
}
if let Some(v) = &audio_decoder {
load_params.insert("audio_decoder".into(), serde_json::Value::String(v.clone()));
}
if let Some(v) = &audio_tokenizer {
load_params.insert(
"audio_tokenizer".into(),
serde_json::Value::String(v.clone()),
);
}
for (k, v) in &extras_str {
load_params.insert(k.clone(), serde_json::Value::String(v.clone()));
}
if let Some(t) = &files.chat_template {
load_params.insert("chat_template".into(), serde_json::Value::String(t.clone()));
}
let mut raw_map = serde_json::Map::new();
raw_map.insert(
"inference_type".into(),
serde_json::Value::String(inference_type.as_str().to_string()),
);
raw_map.insert(
"schema_version".into(),
serde_json::Value::String("1.0.0".into()),
);
raw_map.insert(
"load_time_parameters".into(),
serde_json::Value::Object(load_params),
);
let defaults_shape = inference_type_defaults_shape(&inference_type);
Ok(Manifest {
inference_type,
schema_version: "1.0.0".into(),
files: ManifestFiles {
model: model_str,
multimodal_projector: mmproj,
audio_decoder,
audio_tokenizer,
extras: extras_str,
},
chat_template: files.chat_template.clone(),
generation_defaults: match defaults_shape {
DefaultsShape::Text => crate::manifest::GenerationDefaults::Text {
temperature: None,
min_p: None,
top_p: None,
top_k: None,
repetition_penalty: None,
},
DefaultsShape::Audio => crate::manifest::GenerationDefaults::Audio {
number_of_decoding_threads: None,
},
DefaultsShape::Other => crate::manifest::GenerationDefaults::Other {
raw: serde_json::Value::Null,
},
},
raw: serde_json::Value::Object(raw_map),
})
}
#[cfg(feature = "mmap")]
enum DefaultsShape {
Text,
Audio,
Other,
}
#[cfg(feature = "mmap")]
fn inference_type_defaults_shape(it: &InferenceType) -> DefaultsShape {
match it {
InferenceType::LlamaCppLfm2AudioV1 => DefaultsShape::Audio,
InferenceType::LlamaCppTextToText | InferenceType::LlamaCppImageToText => {
DefaultsShape::Text
}
InferenceType::Unknown(_) => DefaultsShape::Other,
}
}
#[cfg(feature = "mmap")]
fn try_load_audio_encoder(manifest: &Manifest) -> Option<Arc<AudioEncoderWeights>> {
if !matches!(manifest.inference_type, InferenceType::LlamaCppLfm2AudioV1) {
return None;
}
let mmproj_path = manifest.files.multimodal_projector.as_ref()?;
let path = Path::new(mmproj_path);
let gguf = match GgufFile::open(path) {
Ok(g) => Arc::new(g),
Err(e) => {
tracing::warn!(
target: "cera::engine",
path = %path.display(),
error = %format!("{e:#}"),
"audio mmproj GGUF failed to open; audio input will surface \
as 'no audio encoder attached' until a working mmproj is supplied"
);
return None;
}
};
match AudioEncoderWeights::from_gguf(&gguf) {
Ok(w) => Some(Arc::new(w)),
Err(e) => {
tracing::warn!(
target: "cera::engine",
path = %path.display(),
error = %format!("{e:#}"),
"audio mmproj GGUF parsed but encoder weights failed to load; \
audio input will surface as 'no audio encoder attached'"
);
None
}
}
}
#[cfg(not(feature = "mmap"))]
fn try_load_audio_encoder(_manifest: &Manifest) -> Option<Arc<AudioEncoderWeights>> {
None
}
#[cfg(feature = "mmap")]
fn try_load_vision_encoder_gguf(manifest: &Manifest) -> Option<Arc<GgufFile>> {
if !matches!(manifest.inference_type, InferenceType::LlamaCppImageToText) {
return None;
}
let mmproj_path = manifest.files.multimodal_projector.as_ref()?;
let path = Path::new(mmproj_path);
match GgufFile::open(path) {
Ok(g) => Some(Arc::new(g)),
Err(e) => {
tracing::warn!(
target: "cera::engine",
path = %path.display(),
error = %format!("{e:#}"),
"vision mmproj GGUF failed to open; image input will surface \
as 'no vision encoder attached' once that path lands. \
Text-only chat against this bundle still works."
);
None
}
}
}
#[cfg(not(feature = "mmap"))]
fn try_load_vision_encoder_gguf(_manifest: &Manifest) -> Option<Arc<GgufFile>> {
None
}
fn try_parse_vision_encoder(
gguf: &Arc<GgufFile>,
path: Option<&str>,
) -> Option<Arc<VisionEncoderWeights>> {
match VisionEncoderWeights::from_gguf(gguf) {
Ok(w) => Some(Arc::new(w)),
Err(e) => {
tracing::warn!(
target: "cera::engine",
path = %path.unwrap_or("<in-memory>"),
error = %format!("{e:#}"),
"vision mmproj parsed-into-weights step failed; image \
input will surface as 'no vision encoder attached' \
once that path lands. Text-only chat against this \
bundle still works."
);
None
}
}
}
fn check_inference_type_supported(it: &InferenceType) -> Result<(), CeraError> {
match it {
InferenceType::LlamaCppTextToText
| InferenceType::LlamaCppLfm2AudioV1
| InferenceType::LlamaCppImageToText => Ok(()),
InferenceType::Unknown(s) => Err(CeraError::UnsupportedInferenceType(s.clone())),
}
}
#[cfg(feature = "mmap")]
fn auto_detect_inference_type(model_path: &Path) -> Result<InferenceType, CeraError> {
let gguf = GgufFile::open(model_path).map_err(|e| {
CeraError::Backend(format!(
"opening `{}` for inference-type auto-detect: {e}",
model_path.display()
))
})?;
let arch = gguf.get_str("general.architecture").unwrap_or("");
Ok(match arch {
"lfm2" | "llama" | "qwen2" | "qwen3" => InferenceType::LlamaCppTextToText,
"lfm2vl" => InferenceType::LlamaCppImageToText,
"lfm2-audio" => InferenceType::LlamaCppLfm2AudioV1,
_ => InferenceType::LlamaCppTextToText,
})
}
fn load_text_model(
gguf: GgufFile,
path: Option<&Path>,
cfg: &EngineConfig,
) -> Result<Box<dyn Model>, CeraError> {
crate::backend::cpu_features::cpu_features()
.ensure_supported()
.map_err(CeraError::Backend)?;
match cfg.backend {
BackendPreference::Auto => load_text_model_auto(gguf, path, cfg.context_size),
BackendPreference::Cpu => model::load_model(gguf, path, cfg.context_size)
.map_err(|e| CeraError::Backend(format!("CPU model load failed: {e}"))),
#[cfg(feature = "gpu")]
BackendPreference::Gpu => model::load_model_gpu(gguf, path, cfg.context_size)
.map_err(|e| CeraError::Backend(format!("GPU model load failed: {e}"))),
#[cfg(not(feature = "gpu"))]
BackendPreference::Gpu => Err(CeraError::Backend(
"GPU backend not available (compile with --features gpu)".into(),
)),
#[cfg(all(feature = "metal", target_os = "macos"))]
BackendPreference::Metal => {
let p = path.ok_or_else(|| {
CeraError::Backend("Metal backend requires a file path (not from_bytes)".into())
})?;
model::load_model_metal(gguf, p, cfg.context_size)
.map_err(|e| CeraError::Backend(format!("Metal model load failed: {e}")))
}
#[cfg(not(all(feature = "metal", target_os = "macos")))]
BackendPreference::Metal => Err(CeraError::Backend(
"Metal backend not available (compile with --features metal on macOS)".into(),
)),
}
}
fn load_text_model_auto(
gguf: GgufFile,
path: Option<&Path>,
context_size: usize,
) -> Result<Box<dyn Model>, CeraError> {
if path.is_none() {
tracing::debug!("cera::engine: no path available (from_bytes); using CPU backend (auto)");
return model::load_model(gguf, None, context_size)
.map_err(|e| CeraError::Backend(format!("CPU model load failed: {e}")));
}
#[cfg(all(feature = "metal", target_os = "macos"))]
if let Some(p) = path {
match model::load_model_metal(clone_gguf_like(&gguf, p)?, p, context_size) {
Ok(m) => {
tracing::debug!("cera::engine: using native Metal backend (auto)");
return Ok(m);
}
Err(e) => {
tracing::debug!("cera::engine: Metal unavailable ({e}); trying next backend");
}
}
}
#[cfg(all(feature = "gpu", feature = "mmap"))]
{
let p = path.expect("path guaranteed by early return");
let gguf_for_gpu = clone_gguf_like(&gguf, p)?;
match model::load_model_gpu(gguf_for_gpu, Some(p), context_size) {
Ok(m) => {
tracing::debug!("cera::engine: using wgpu GPU backend (auto)");
return Ok(m);
}
Err(e) => {
tracing::debug!("cera::engine: wgpu unavailable ({e}); falling back to CPU");
}
}
let gguf_for_cpu = GgufFile::open(p).map_err(|e| {
CeraError::Backend(format!("reopening `{}` for CPU fallback: {e}", p.display()))
})?;
model::load_model(gguf_for_cpu, Some(p), context_size)
.map_err(|e| CeraError::Backend(format!("CPU model load failed: {e}")))
}
#[cfg(not(all(feature = "gpu", feature = "mmap")))]
{
tracing::debug!("cera::engine: using CPU backend (auto)");
model::load_model(gguf, path, context_size)
.map_err(|e| CeraError::Backend(format!("CPU model load failed: {e}")))
}
}
#[cfg(all(
feature = "mmap",
any(all(feature = "metal", target_os = "macos"), feature = "gpu")
))]
fn clone_gguf_like(_: &GgufFile, path: &Path) -> Result<GgufFile, CeraError> {
GgufFile::open(path)
.map_err(|e| CeraError::Backend(format!("reopening `{}`: {e}", path.display())))
}
fn build_metadata(
model: &dyn Model,
tokenizer: &BpeTokenizer,
manifest: &Manifest,
add_bos_token: bool,
quantization: String,
) -> ModelMetadata {
let cfg = model.config();
let has_chat_template = manifest.chat_template.is_some() || tokenizer.chat_template().is_some();
ModelMetadata {
architecture: cfg.architecture.clone(),
max_seq_len: cfg.max_seq_len as u32,
vocab_size: cfg.vocab_size as u32,
has_chat_template,
quantization,
add_bos_token,
}
}
fn ftype_label(ftype: u32) -> String {
match ftype {
0 => "F32".into(),
1 => "F16".into(),
2 => "Q4_0".into(),
3 => "Q4_1".into(),
7 => "Q8_0".into(),
8 => "Q5_0".into(),
9 => "Q5_1".into(),
10 => "Q2_K".into(),
11 => "Q3_K_S".into(),
12 => "Q3_K_M".into(),
13 => "Q3_K_L".into(),
14 => "Q4_K_S".into(),
15 => "Q4_K_M".into(),
16 => "Q5_K_S".into(),
17 => "Q5_K_M".into(),
18 => "Q6_K".into(),
19 => "IQ2_XXS".into(),
20 => "IQ2_XS".into(),
21 => "Q2_K_S".into(),
22 => "IQ3_XS".into(),
23 => "IQ3_XXS".into(),
24 => "IQ1_S".into(),
25 => "IQ4_NL".into(),
26 => "IQ3_S".into(),
27 => "IQ3_M".into(),
28 => "IQ2_S".into(),
29 => "IQ2_M".into(),
30 => "IQ4_XS".into(),
31 => "IQ1_M".into(),
32 => "BF16".into(),
36 => "TQ1_0".into(),
37 => "TQ2_0".into(),
other => format!("ftype:{other}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_preference_default_is_auto() {
assert_eq!(BackendPreference::default(), BackendPreference::Auto);
}
#[test]
fn backend_preference_parse_str_known_labels() {
assert_eq!(
BackendPreference::parse_str("auto").unwrap(),
BackendPreference::Auto
);
assert_eq!(
BackendPreference::parse_str("").unwrap(),
BackendPreference::Auto
);
assert_eq!(
BackendPreference::parse_str("CPU").unwrap(),
BackendPreference::Cpu
);
assert_eq!(
BackendPreference::parse_str("gpu").unwrap(),
BackendPreference::Gpu
);
assert_eq!(
BackendPreference::parse_str("wgpu").unwrap(),
BackendPreference::Gpu
);
assert_eq!(
BackendPreference::parse_str("Metal").unwrap(),
BackendPreference::Metal
);
assert!(BackendPreference::parse_str("nvidia").is_err());
}
#[test]
fn engine_config_default_is_4k_auto() {
let c = EngineConfig::default();
assert_eq!(c.context_size, 4096);
assert_eq!(c.backend, BackendPreference::Auto);
}
#[test]
fn is_remote_url_covers_http_https() {
assert!(is_remote_url("http://example.com/x.gguf"));
assert!(is_remote_url("HTTPS://example.com/x.gguf"));
assert!(!is_remote_url("/local/path.gguf"));
assert!(!is_remote_url("./rel/path.gguf"));
assert!(!is_remote_url("file:///local/path.gguf"));
}
#[test]
fn has_extension_case_insensitive() {
assert!(has_extension(Path::new("foo.gguf"), "gguf"));
assert!(has_extension(Path::new("foo.GGUF"), "gguf"));
assert!(has_extension(Path::new("foo.json"), "json"));
assert!(!has_extension(Path::new("foo.txt"), "gguf"));
assert!(!has_extension(Path::new("foo"), "gguf"));
}
#[test]
fn resolve_url_or_path_rejects_remote_without_repo() {
let cfg = EngineConfig::default();
let e = resolve_url_or_path("https://hf.co/x.gguf", None, &cfg)
.expect_err("remote URL must error without a BundleRepo");
let msg = format!("{e}");
assert!(
msg.contains("remote URL"),
"error should mention remote URL; got `{msg}`"
);
#[cfg(feature = "remote")]
assert!(
msg.contains("bundle_repo"),
"error under `remote` feature should point at the config field; got `{msg}`"
);
#[cfg(not(feature = "remote"))]
assert!(
msg.contains("`remote` feature"),
"error without `remote` feature should point at enabling it; got `{msg}`"
);
}
#[test]
fn resolve_url_or_path_rejects_file_scheme() {
let cfg = EngineConfig::default();
let e = resolve_url_or_path("file:///models/x.gguf", None, &cfg)
.expect_err("file:// URIs aren't supported yet");
let msg = format!("{e}");
assert!(
msg.contains("file://") && msg.contains("cera doesn't parse file URIs"),
"error should point at the file:// limitation; got `{msg}`"
);
}
#[test]
fn strip_file_scheme_preserves_case() {
assert_eq!(
strip_file_scheme("FILE:///Models/Foo.gguf"),
Some("/Models/Foo.gguf")
);
assert_eq!(strip_file_scheme("file://./rel"), Some("./rel"));
assert_eq!(strip_file_scheme("https://x/y"), None);
assert_eq!(strip_file_scheme("/abs/path"), None);
}
#[test]
fn resolve_url_or_path_joins_relative_against_base() {
let cfg = EngineConfig::default();
let base = PathBuf::from("/models/bundles");
let got = resolve_url_or_path("LFM2-1.2B-Q4_0.gguf", Some(&base), &cfg).unwrap();
assert_eq!(got, PathBuf::from("/models/bundles/LFM2-1.2B-Q4_0.gguf"));
}
#[test]
fn resolve_url_or_path_keeps_absolute_unchanged() {
let cfg = EngineConfig::default();
let base = PathBuf::from("/models/bundles");
let got = resolve_url_or_path("/opt/foo.gguf", Some(&base), &cfg).unwrap();
assert_eq!(got, PathBuf::from("/opt/foo.gguf"));
}
#[test]
fn resolve_all_manifest_files_walks_every_field() {
use crate::manifest::{GenerationDefaults, InferenceType, Manifest, ManifestFiles};
let base = PathBuf::from("/models/bundles");
let mut extras = std::collections::HashMap::new();
extras.insert("cover_art".to_string(), "cover.png".to_string());
extras.insert("config".to_string(), "/abs/config.toml".to_string());
let mut manifest = Manifest {
inference_type: InferenceType::LlamaCppLfm2AudioV1,
schema_version: "1.0.0".to_string(),
files: ManifestFiles {
model: "model.gguf".to_string(),
multimodal_projector: Some("mmproj.gguf".to_string()),
audio_decoder: Some("decoder.gguf".to_string()),
audio_tokenizer: Some("tokenizer.safetensors".to_string()),
extras,
},
chat_template: None,
generation_defaults: GenerationDefaults::Other {
raw: serde_json::Value::Null,
},
raw: serde_json::Value::Null,
};
let cfg = EngineConfig::default();
resolve_all_manifest_files(&mut manifest, Some(&base), &cfg).unwrap();
assert_eq!(manifest.files.model, "/models/bundles/model.gguf");
assert_eq!(
manifest.files.multimodal_projector.as_deref(),
Some("/models/bundles/mmproj.gguf")
);
assert_eq!(
manifest.files.audio_decoder.as_deref(),
Some("/models/bundles/decoder.gguf")
);
assert_eq!(
manifest.files.audio_tokenizer.as_deref(),
Some("/models/bundles/tokenizer.safetensors")
);
assert_eq!(
manifest.files.extras.get("cover_art").map(String::as_str),
Some("/models/bundles/cover.png")
);
assert_eq!(
manifest.files.extras.get("config").map(String::as_str),
Some("/abs/config.toml")
);
}
#[test]
fn resolve_all_manifest_files_none_optionals_stay_none() {
use crate::manifest::{GenerationDefaults, InferenceType, Manifest, ManifestFiles};
let mut manifest = Manifest {
inference_type: InferenceType::LlamaCppTextToText,
schema_version: "1.0.0".to_string(),
files: ManifestFiles {
model: "/abs/model.gguf".to_string(),
multimodal_projector: None,
audio_decoder: None,
audio_tokenizer: None,
extras: std::collections::HashMap::new(),
},
chat_template: None,
generation_defaults: GenerationDefaults::Other {
raw: serde_json::Value::Null,
},
raw: serde_json::Value::Null,
};
let cfg = EngineConfig::default();
resolve_all_manifest_files(&mut manifest, None, &cfg).unwrap();
assert!(manifest.files.multimodal_projector.is_none());
assert!(manifest.files.audio_decoder.is_none());
assert!(manifest.files.audio_tokenizer.is_none());
}
#[test]
fn find_single_manifest_zero_and_many() {
let dir = tempfile::tempdir().unwrap();
let e0 = find_single_manifest(dir.path()).expect_err("empty dir must error");
assert!(format!("{e0}").contains("no .json manifest"));
std::fs::write(dir.path().join("a.json"), b"{}").unwrap();
let got = find_single_manifest(dir.path()).unwrap();
assert_eq!(got.file_name().unwrap(), "a.json");
std::fs::write(dir.path().join("b.json"), b"{}").unwrap();
let e2 =
find_single_manifest(dir.path()).expect_err("two manifests must error (ambiguous)");
let msg = format!("{e2}");
assert!(msg.contains("2 .json manifests"), "{msg}");
assert!(msg.contains("a.json") && msg.contains("b.json"), "{msg}");
}
#[test]
fn synthesize_manifest_from_files_preserves_aux() {
let files = ModelFiles {
model: PathBuf::from("/m/model.gguf"),
multimodal_projector: Some(PathBuf::from("/m/mmproj.gguf")),
audio_decoder: Some(PathBuf::from("/m/ad.gguf")),
audio_tokenizer: Some(PathBuf::from("/m/at.safetensors")),
extras: std::collections::HashMap::new(),
inference_type: Some(InferenceType::LlamaCppLfm2AudioV1),
chat_template: None,
};
let m = synthesize_manifest_from_files(&files).unwrap();
assert_eq!(m.inference_type, InferenceType::LlamaCppLfm2AudioV1);
assert_eq!(m.files.model, "/m/model.gguf");
assert_eq!(
m.files.multimodal_projector.as_deref(),
Some("/m/mmproj.gguf")
);
assert_eq!(m.files.audio_decoder.as_deref(), Some("/m/ad.gguf"));
assert_eq!(
m.files.audio_tokenizer.as_deref(),
Some("/m/at.safetensors")
);
assert!(matches!(
m.generation_defaults,
crate::manifest::GenerationDefaults::Audio { .. }
));
}
#[test]
fn model_files_text_helper_is_text_only() {
let f = ModelFiles::text("/x/y.gguf");
assert_eq!(f.model, PathBuf::from("/x/y.gguf"));
assert!(f.multimodal_projector.is_none());
assert_eq!(f.inference_type, Some(InferenceType::LlamaCppTextToText));
}
}