use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct Manifest {
pub inference_type: InferenceType,
pub schema_version: String,
pub files: ManifestFiles,
pub chat_template: Option<String>,
pub generation_defaults: GenerationDefaults,
pub raw: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InferenceType {
LlamaCppTextToText,
LlamaCppImageToText,
LlamaCppLfm2AudioV1,
Unknown(String),
}
impl InferenceType {
pub fn parse_str(s: &str) -> Self {
match s {
"llama.cpp/text-to-text" => Self::LlamaCppTextToText,
"llama.cpp/image-to-text" => Self::LlamaCppImageToText,
"llama.cpp/lfm2-audio-v1" => Self::LlamaCppLfm2AudioV1,
other => Self::Unknown(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::LlamaCppTextToText => "llama.cpp/text-to-text",
Self::LlamaCppImageToText => "llama.cpp/image-to-text",
Self::LlamaCppLfm2AudioV1 => "llama.cpp/lfm2-audio-v1",
Self::Unknown(s) => s,
}
}
}
#[derive(Debug, Clone)]
pub struct ManifestFiles {
pub model: String,
pub multimodal_projector: Option<String>,
pub audio_decoder: Option<String>,
pub audio_tokenizer: Option<String>,
pub draft_model: Option<String>,
pub extras: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum GenerationDefaults {
Text {
temperature: Option<f32>,
min_p: Option<f32>,
top_p: Option<f32>,
top_k: Option<u32>,
repetition_penalty: Option<f32>,
},
Audio {
number_of_decoding_threads: Option<u32>,
audio_temperature: Option<f32>,
audio_top_k: Option<u32>,
temperature: Option<f32>,
min_p: Option<f32>,
top_p: Option<f32>,
top_k: Option<u32>,
repetition_penalty: Option<f32>,
},
Other { raw: serde_json::Value },
}
impl GenerationDefaults {
pub fn to_json_value(&self) -> serde_json::Value {
match self {
Self::Text {
temperature,
min_p,
top_p,
top_k,
repetition_penalty,
} => {
let mut params = serde_json::Map::new();
if let Some(t) = temperature {
params.insert("temperature".into(), serde_json::json!(t));
}
if let Some(mp) = min_p {
params.insert("min_p".into(), serde_json::json!(mp));
}
if let Some(tp) = top_p {
params.insert("top_p".into(), serde_json::json!(tp));
}
if let Some(tk) = top_k {
params.insert("top_k".into(), serde_json::json!(tk));
}
if let Some(rp) = repetition_penalty {
params.insert("repetition_penalty".into(), serde_json::json!(rp));
}
serde_json::json!({
"sampling_parameters": params
})
}
Self::Audio {
number_of_decoding_threads,
audio_temperature,
audio_top_k,
temperature,
min_p,
top_p,
top_k,
repetition_penalty,
} => {
let mut params = serde_json::Map::new();
if let Some(t) = number_of_decoding_threads {
params.insert("number_of_decoding_threads".into(), serde_json::json!(t));
}
if let Some(at) = audio_temperature {
params.insert("audio_temperature".into(), serde_json::json!(at));
}
if let Some(atk) = audio_top_k {
params.insert("audio_top_k".into(), serde_json::json!(atk));
}
if let Some(t) = temperature {
params.insert("temperature".into(), serde_json::json!(t));
}
if let Some(mp) = min_p {
params.insert("min_p".into(), serde_json::json!(mp));
}
if let Some(tp) = top_p {
params.insert("top_p".into(), serde_json::json!(tp));
}
if let Some(tk) = top_k {
params.insert("top_k".into(), serde_json::json!(tk));
}
if let Some(rp) = repetition_penalty {
params.insert("repetition_penalty".into(), serde_json::json!(rp));
}
serde_json::Value::Object(params)
}
Self::Other { raw } => raw.clone(),
}
}
}
impl Manifest {
#[cfg(feature = "std-fs")]
pub fn from_file(path: &Path) -> Result<Self> {
let bytes = std::fs::read(path)
.with_context(|| format!("reading manifest file: {}", path.display()))?;
Self::from_bytes(&bytes)
.with_context(|| format!("parsing manifest file: {}", path.display()))
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
let raw: serde_json::Value =
serde_json::from_slice(bytes).context("manifest is not valid JSON")?;
Self::from_value(raw)
}
pub fn from_value(raw: serde_json::Value) -> Result<Self> {
let shadow: RawManifest = serde_json::from_value(raw.clone())
.context("manifest doesn't match the expected LeapBundles schema")?;
let files = ManifestFiles::from_raw(&shadow.load_time_parameters)?;
let chat_template = shadow.load_time_parameters.chat_template.clone();
let inference_type = InferenceType::parse_str(&shadow.inference_type);
let generation_defaults = GenerationDefaults::from_raw(
&inference_type,
shadow.generation_time_parameters.as_ref(),
);
Ok(Self {
inference_type,
schema_version: shadow.schema_version,
files,
chat_template,
generation_defaults,
raw,
})
}
pub fn synthetic_text(model_path: &Path) -> Self {
Self::synthetic(model_path, InferenceType::LlamaCppTextToText, None)
}
pub fn synthetic(
model_path: &Path,
inference_type: InferenceType,
multimodal_projector: Option<String>,
) -> Self {
let model_path_str = model_path.to_string_lossy().into_owned();
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()),
);
let mut load_params = serde_json::Map::new();
load_params.insert(
"model".into(),
serde_json::Value::String(model_path_str.clone()),
);
if let Some(mmproj) = &multimodal_projector {
load_params.insert(
"multimodal_projector".into(),
serde_json::Value::String(mmproj.clone()),
);
}
raw_map.insert(
"load_time_parameters".into(),
serde_json::Value::Object(load_params),
);
let raw = serde_json::Value::Object(raw_map);
Self {
inference_type,
schema_version: "1.0.0".into(),
files: ManifestFiles {
model: model_path_str,
multimodal_projector,
audio_decoder: None,
audio_tokenizer: None,
draft_model: None,
extras: HashMap::new(),
},
chat_template: None,
generation_defaults: GenerationDefaults::Text {
temperature: None,
min_p: None,
top_p: None,
top_k: None,
repetition_penalty: None,
},
raw,
}
}
pub fn is_loadable(&self) -> bool {
matches!(
self.inference_type,
InferenceType::LlamaCppTextToText
| InferenceType::LlamaCppLfm2AudioV1
| InferenceType::LlamaCppImageToText
)
}
pub fn files_in_order(&self) -> Vec<(&str, &str)> {
let mut out: Vec<(&str, &str)> = Vec::with_capacity(1 + 4 + self.files.extras.len());
out.push(("model", self.files.model.as_str()));
if let Some(v) = &self.files.multimodal_projector {
out.push(("multimodal_projector", v.as_str()));
}
if let Some(v) = &self.files.audio_decoder {
out.push(("audio_decoder", v.as_str()));
}
if let Some(v) = &self.files.audio_tokenizer {
out.push(("audio_tokenizer", v.as_str()));
}
if let Some(v) = &self.files.draft_model {
out.push(("draft_model", v.as_str()));
}
let mut extras: Vec<(&String, &String)> = self.files.extras.iter().collect();
extras.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in extras {
out.push((k.as_str(), v.as_str()));
}
out
}
}
impl ManifestFiles {
fn from_raw(raw: &RawLoadTimeParameters) -> Result<Self> {
let model = raw
.other
.get("model")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("manifest load_time_parameters missing required `model` field"))?
.to_string();
let take_str = |key: &str| -> Option<String> {
raw.other
.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let multimodal_projector = take_str("multimodal_projector");
let audio_decoder = take_str("audio_decoder");
let audio_tokenizer = take_str("audio_tokenizer");
let draft_model = take_str("draft_model");
const KNOWN_KEYS: &[&str] = &[
"model",
"multimodal_projector",
"audio_decoder",
"audio_tokenizer",
"draft_model",
];
let mut extras = HashMap::with_capacity(raw.other.len());
for (k, v) in &raw.other {
if KNOWN_KEYS.contains(&k.as_str()) {
continue;
}
if let Some(s) = v.as_str() {
let trimmed = s.trim();
if !trimmed.is_empty() {
extras.insert(k.clone(), trimmed.to_string());
}
}
}
Ok(Self {
model,
multimodal_projector,
audio_decoder,
audio_tokenizer,
draft_model,
extras,
})
}
pub fn resolve_local<F>(&self, mut local_root_for_url: F) -> Vec<(String, PathBuf)>
where
F: FnMut(&str) -> PathBuf,
{
let mut out: Vec<(String, PathBuf)> = Vec::with_capacity(1 + 4 + self.extras.len());
out.push(("model".into(), local_root_for_url(&self.model)));
if let Some(v) = &self.multimodal_projector {
out.push(("multimodal_projector".into(), local_root_for_url(v)));
}
if let Some(v) = &self.audio_decoder {
out.push(("audio_decoder".into(), local_root_for_url(v)));
}
if let Some(v) = &self.audio_tokenizer {
out.push(("audio_tokenizer".into(), local_root_for_url(v)));
}
if let Some(v) = &self.draft_model {
out.push(("draft_model".into(), local_root_for_url(v)));
}
let mut extras: Vec<(&String, &String)> = self.extras.iter().collect();
extras.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in extras {
out.push((k.clone(), local_root_for_url(v)));
}
out
}
}
impl GenerationDefaults {
fn from_raw(inference_type: &InferenceType, raw: Option<&serde_json::Value>) -> Self {
let Some(raw) = raw else {
return match inference_type {
InferenceType::LlamaCppLfm2AudioV1 => Self::Audio {
number_of_decoding_threads: None,
audio_temperature: None,
audio_top_k: None,
temperature: None,
min_p: None,
top_p: None,
top_k: None,
repetition_penalty: None,
},
InferenceType::LlamaCppTextToText | InferenceType::LlamaCppImageToText => {
Self::Text {
temperature: None,
min_p: None,
top_p: None,
top_k: None,
repetition_penalty: None,
}
}
InferenceType::Unknown(_) => Self::Other {
raw: serde_json::Value::Null,
},
};
};
match inference_type {
InferenceType::LlamaCppLfm2AudioV1 => {
let ndt = raw
.get("number_of_decoding_threads")
.and_then(|v| v.as_u64())
.and_then(|n| u32::try_from(n).ok());
let sp = raw.get("sampling_parameters");
let f32_at = |key: &str| -> Option<f32> {
raw.get(key)
.or_else(|| sp.and_then(|o| o.get(key)))
.and_then(|v| v.as_f64())
.map(|v| v as f32)
};
let u32_at = |key: &str| -> Option<u32> {
raw.get(key)
.or_else(|| sp.and_then(|o| o.get(key)))
.and_then(|v| v.as_u64())
.and_then(|n| u32::try_from(n).ok())
};
Self::Audio {
number_of_decoding_threads: ndt,
audio_temperature: f32_at("audio_temperature"),
audio_top_k: u32_at("audio_top_k"),
temperature: f32_at("temperature"),
min_p: f32_at("min_p"),
top_p: f32_at("top_p"),
top_k: u32_at("top_k"),
repetition_penalty: f32_at("repetition_penalty"),
}
}
InferenceType::LlamaCppTextToText | InferenceType::LlamaCppImageToText => {
let sp = raw.get("sampling_parameters");
let f32_at = |key: &str| -> Option<f32> {
sp.and_then(|o| o.get(key))
.and_then(|v| v.as_f64())
.map(|v| v as f32)
};
let u32_at = |key: &str| -> Option<u32> {
sp.and_then(|o| o.get(key))
.and_then(|v| v.as_u64())
.and_then(|n| u32::try_from(n).ok())
};
Self::Text {
temperature: f32_at("temperature"),
min_p: f32_at("min_p"),
top_p: f32_at("top_p"),
top_k: u32_at("top_k"),
repetition_penalty: f32_at("repetition_penalty"),
}
}
InferenceType::Unknown(_) => Self::Other { raw: raw.clone() },
}
}
}
#[derive(Deserialize, Serialize, Debug)]
struct RawManifest {
inference_type: String,
schema_version: String,
load_time_parameters: RawLoadTimeParameters,
#[serde(default)]
generation_time_parameters: Option<serde_json::Value>,
}
#[derive(Deserialize, Serialize, Debug)]
struct RawLoadTimeParameters {
#[serde(default)]
chat_template: Option<String>,
#[serde(flatten)]
other: HashMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inference_type_roundtrip() {
for s in [
"llama.cpp/text-to-text",
"llama.cpp/image-to-text",
"llama.cpp/lfm2-audio-v1",
"llama.cpp/some-new-modality",
] {
let t = InferenceType::parse_str(s);
assert_eq!(t.as_str(), s, "round-trip failed for {s}");
}
}
#[test]
fn synthetic_text_shape() {
let p = std::path::Path::new("/tmp/model.gguf");
let m = Manifest::synthetic_text(p);
assert_eq!(m.inference_type, InferenceType::LlamaCppTextToText);
assert_eq!(m.files.model, "/tmp/model.gguf");
assert!(m.files.multimodal_projector.is_none());
assert!(m.chat_template.is_none());
assert!(m.is_loadable());
}
#[test]
fn files_in_order_stable() {
let m = ManifestFiles {
model: "m.gguf".into(),
multimodal_projector: Some("mm.gguf".into()),
audio_decoder: Some("ad.gguf".into()),
audio_tokenizer: Some("at.safetensors".into()),
draft_model: None,
extras: {
let mut e = HashMap::new();
e.insert("zzz_future".into(), "z.bin".into());
e.insert("aaa_novel".into(), "a.bin".into());
e
},
};
let manifest = Manifest {
inference_type: InferenceType::LlamaCppLfm2AudioV1,
schema_version: "1.0.0".into(),
files: m,
chat_template: None,
generation_defaults: GenerationDefaults::Audio {
number_of_decoding_threads: Some(4),
audio_temperature: None,
audio_top_k: None,
temperature: None,
min_p: None,
top_p: None,
top_k: None,
repetition_penalty: None,
},
raw: serde_json::Value::Null,
};
let out: Vec<_> = manifest
.files_in_order()
.iter()
.map(|(k, _)| k.to_string())
.collect();
assert_eq!(
out,
vec![
"model",
"multimodal_projector",
"audio_decoder",
"audio_tokenizer",
"aaa_novel",
"zzz_future",
]
);
}
#[test]
fn parse_fails_on_missing_model() {
let bad = br#"{
"inference_type": "llama.cpp/text-to-text",
"schema_version": "1.0.0",
"load_time_parameters": {}
}"#;
let err = Manifest::from_bytes(bad).unwrap_err();
assert!(
err.to_string().contains("missing required `model`"),
"unexpected error: {err}"
);
}
#[test]
fn parse_fails_on_non_json() {
let err = Manifest::from_bytes(b"not json").unwrap_err();
assert!(
err.to_string().to_lowercase().contains("not valid json"),
"unexpected error: {err}"
);
}
#[test]
fn parse_defaults_missing_generation_params() {
let min = br#"{
"inference_type": "llama.cpp/text-to-text",
"schema_version": "1.0.0",
"load_time_parameters": { "model": "m.gguf" }
}"#;
let m = Manifest::from_bytes(min).unwrap();
match &m.generation_defaults {
GenerationDefaults::Text {
temperature,
min_p,
top_p,
top_k,
repetition_penalty,
} => {
assert!(temperature.is_none());
assert!(min_p.is_none());
assert!(top_p.is_none());
assert!(top_k.is_none());
assert!(repetition_penalty.is_none());
}
other => panic!("expected Text defaults, got {other:?}"),
}
}
}