use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use ffai_core::error::{Error, Result};
use serde::Deserialize;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone)]
pub struct ResolvedModel {
pub name: String,
pub license: String,
pub files: BTreeMap<String, PathBuf>,
}
impl ResolvedModel {
pub fn file(&self, name: &str) -> Result<&Path> {
self.files
.get(name)
.map(PathBuf::as_path)
.ok_or_else(|| Error::Model(format!("model `{}` has no file `{name}`", self.name)))
}
}
#[cfg(feature = "fetch")]
fn hub_download(repo: &str, filename: &str, cache_only: bool) -> Result<PathBuf> {
let (owner, name) = repo
.split_once('/')
.ok_or_else(|| Error::Model(format!("hf_repo `{repo}` is not in `owner/name` form")))?;
let client = hf_hub::HFClientSync::new()
.map_err(|e| Error::Model(format!("hugging face client init failed: {e}")))?;
client
.model(owner, name)
.download_file()
.filename(filename)
.local_files_only(cache_only)
.send()
.map_err(|e| Error::Model(format!("{repo}/{filename}: {e}")))
}
#[cfg(not(feature = "fetch"))]
fn hub_download(repo: &str, filename: &str, _cache_only: bool) -> Result<PathBuf> {
Err(Error::Model(format!(
"{repo}/{filename} is not present locally and this build has the `fetch` feature disabled — place the file in the model directory, or enable `ffai-models/fetch` to download it"
)))
}
fn verify_checksum(path: &Path, file: &ModelFile) -> Result<()> {
let Some(expected) = &file.sha256 else {
return Ok(());
};
let bytes = std::fs::read(path)?;
let actual: String = Sha256::digest(&bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
if actual != expected.to_ascii_lowercase() {
return Err(Error::Model(format!(
"checksum mismatch for {}: manifest says {expected}, file is {actual}",
path.display()
)));
}
Ok(())
}
#[derive(Debug, Clone, Deserialize)]
pub struct ModelFile {
pub name: String,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ModelManifest {
pub name: String,
pub task: String,
pub description: Option<String>,
pub license: String,
pub hf_repo: Option<String>,
#[serde(default)]
pub files: Vec<ModelFile>,
}
impl ModelManifest {
pub fn from_toml(text: &str) -> Result<Self> {
toml::from_str(text).map_err(|e| Error::Model(format!("bad manifest: {e}")))
}
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)?;
Self::from_toml(&text)
}
#[must_use]
pub fn cache_path(&self) -> PathBuf {
cache_dir().join("models").join(&self.name)
}
#[must_use]
pub fn is_cached(&self) -> bool {
!self.files.is_empty()
&& self
.files
.iter()
.all(|f| self.local_path(&f.name).is_some())
}
#[must_use]
pub fn local_path(&self, name: &str) -> Option<PathBuf> {
let manual = self.cache_path().join(name);
if manual.exists() {
return Some(manual);
}
hub_download(self.hf_repo.as_ref()?, name, true).ok()
}
pub fn fetch(&self) -> Result<ResolvedModel> {
let mut files = BTreeMap::new();
for file in &self.files {
if let Some(path) = self.local_path(&file.name) {
verify_checksum(&path, file)?;
files.insert(file.name.clone(), path);
continue;
}
let repo = self.hf_repo.as_ref().ok_or_else(|| {
Error::Model(format!(
"model `{}` declares no hf_repo and `{}` is not present under {}",
self.name,
file.name,
self.cache_path().display()
))
})?;
let path = hub_download(repo, &file.name, false)?;
verify_checksum(&path, file)?;
files.insert(file.name.clone(), path);
}
Ok(ResolvedModel {
name: self.name.clone(),
license: self.license.clone(),
files,
})
}
pub fn resolve_in(&self, root: &Path) -> Result<ResolvedModel> {
let all: Vec<&str> = self.files.iter().map(|f| f.name.as_str()).collect();
self.resolve_only_in(root, &all)
}
pub fn resolve_only_in(&self, root: &Path, needed: &[&str]) -> Result<ResolvedModel> {
let dir = root.join(&self.name);
let mut files = BTreeMap::new();
for name in needed {
if !self.files.iter().any(|f| f.name == *name) {
return Err(Error::Model(format!(
"model `{}` declares no file `{name}` — it has no checksum to verify against",
self.name
)));
}
}
for file in self
.files
.iter()
.filter(|f| needed.contains(&f.name.as_str()))
{
let path = dir.join(&file.name);
if !path.is_file() {
return Err(Error::Model(format!(
"model `{}` needs `{}`, expected at {} — not present (offline resolution \
never downloads)",
self.name,
file.name,
path.display()
)));
}
verify_checksum(&path, file)?;
files.insert(file.name.clone(), path);
}
Ok(ResolvedModel {
name: self.name.clone(),
license: self.license.clone(),
files,
})
}
pub fn verify(&self) -> Result<ResolvedModel> {
let mut files = BTreeMap::new();
for file in &self.files {
let path = self.local_path(&file.name).ok_or_else(|| {
Error::Model(format!(
"model `{}` is missing `{}` (looked under {}{})",
self.name,
file.name,
self.cache_path().display(),
if self.hf_repo.is_some() {
" and the Hugging Face cache"
} else {
""
}
))
})?;
verify_checksum(&path, file)?;
files.insert(file.name.clone(), path);
}
Ok(ResolvedModel {
name: self.name.clone(),
license: self.license.clone(),
files,
})
}
}
pub fn load_dir(dir: &Path) -> Result<Vec<ModelManifest>> {
let mut out = Vec::new();
let entries = std::fs::read_dir(dir).map_err(|e| {
Error::Model(format!(
"cannot read model manifest directory {}: {e}",
dir.display()
))
})?;
for entry in entries {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
out.push(
ModelManifest::load(&path)
.map_err(|e| Error::Model(format!("{}: {e}", path.display())))?,
);
}
}
out.sort_by(|a, b| a.task.cmp(&b.task).then_with(|| a.name.cmp(&b.name)));
Ok(out)
}
pub fn cache_dir() -> PathBuf {
if let Ok(dir) = std::env::var("FFAI_CACHE") {
return PathBuf::from(dir);
}
dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
.join("ffai")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_parses_and_surfaces_license() {
let m = ModelManifest::from_toml(
r#"
name = "whisper-tiny"
task = "asr"
license = "Apache-2.0"
hf_repo = "openai/whisper-tiny"
[[files]]
name = "model.safetensors"
"#,
)
.unwrap();
assert_eq!(m.name, "whisper-tiny");
assert_eq!(m.license, "Apache-2.0");
assert_eq!(m.files.len(), 1);
assert!(!m.is_cached());
}
fn scratch(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ffai-models-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn manifest_for(sha: &str) -> ModelManifest {
ModelManifest::from_toml(&format!(
r#"
name = "tiny-test"
task = "ocr"
license = "MIT"
[[files]]
name = "w.bin"
sha256 = "{sha}"
"#
))
.unwrap()
}
const ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
#[test]
fn resolve_in_names_the_exact_missing_path_and_never_fetches() {
let root = scratch("missing");
let err = manifest_for(ABC).resolve_in(&root).unwrap_err().to_string();
let want = root.join("tiny-test").join("w.bin");
assert!(
err.contains(&want.display().to_string()),
"error must name {}: {err}",
want.display()
);
assert!(err.contains("never downloads"), "{err}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn resolve_in_finds_and_checksums_a_placed_file() {
let root = scratch("present");
std::fs::create_dir_all(root.join("tiny-test")).unwrap();
std::fs::write(root.join("tiny-test").join("w.bin"), b"abc").unwrap();
let r = manifest_for(ABC).resolve_in(&root).unwrap();
assert_eq!(
r.file("w.bin").unwrap(),
root.join("tiny-test").join("w.bin")
);
let bad = manifest_for(&"0".repeat(64))
.resolve_in(&root)
.unwrap_err()
.to_string();
assert!(bad.contains("checksum mismatch"), "{bad}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn load_dir_names_a_missing_directory() {
let missing = std::env::temp_dir().join("ffai-models-definitely-absent-dir");
let err = load_dir(&missing).unwrap_err().to_string();
assert!(err.contains(&missing.display().to_string()), "{err}");
}
#[test]
fn resolve_only_in_needs_just_the_named_files_and_refuses_undeclared_ones() {
let root = scratch("subset");
std::fs::create_dir_all(root.join("tiny-test")).unwrap();
std::fs::write(root.join("tiny-test").join("w.bin"), b"abc").unwrap();
let m = ModelManifest::from_toml(&format!(
r#"
name = "tiny-test"
task = "ocr"
license = "MIT"
[[files]]
name = "w.bin"
sha256 = "{ABC}"
[[files]]
name = "train-only.bin"
"#
))
.unwrap();
assert!(
m.resolve_in(&root).is_err(),
"the full set includes an absent file"
);
let r = m.resolve_only_in(&root, &["w.bin"]).unwrap();
assert_eq!(r.files.len(), 1);
let err = m
.resolve_only_in(&root, &["nope.bin"])
.unwrap_err()
.to_string();
assert!(err.contains("declares no file `nope.bin`"), "{err}");
let _ = std::fs::remove_dir_all(&root);
}
}