use crate::error::LlamaError;
use std::path::PathBuf;
use std::sync::Arc;
use super::repo::HfRepo;
pub trait HfDownloader: Send + Sync {
fn get(&self, repo: &HfRepo, filename: &str) -> Result<PathBuf, LlamaError>;
fn list_repo_files(&self, _repo: &HfRepo) -> Result<Vec<String>, LlamaError> {
Ok(vec![])
}
}
#[derive(Debug)]
pub struct MockHfDownloader {
cache: std::sync::Mutex<std::collections::HashMap<(String, String), PathBuf>>,
next_error: std::sync::Mutex<Option<LlamaError>>,
list_files: std::sync::Mutex<std::collections::HashMap<String, Vec<String>>>,
}
impl Default for MockHfDownloader {
fn default() -> Self {
Self {
cache: std::sync::Mutex::new(std::collections::HashMap::new()),
next_error: std::sync::Mutex::new(None),
list_files: std::sync::Mutex::new(std::collections::HashMap::new()),
}
}
}
#[cfg(test)]
impl MockHfDownloader {
#[must_use]
pub fn with_paths(self, repo: &str, filename: &str, path: PathBuf) -> Self {
self.cache
.lock()
.expect("mock cache mutex poisoned")
.insert((repo.to_string(), filename.to_string()), path);
self
}
#[must_use]
pub fn with_files(self, repo: &str, files: Vec<String>) -> Self {
self.list_files
.lock()
.expect("mock list_files mutex poisoned")
.insert(repo.to_string(), files);
self
}
#[must_use]
pub fn with_next_error(self, err: LlamaError) -> Self {
*self
.next_error
.lock()
.expect("mock next_error mutex poisoned") = Some(err);
self
}
}
#[cfg(test)]
impl HfDownloader for MockHfDownloader {
fn get(&self, repo: &HfRepo, filename: &str) -> Result<PathBuf, LlamaError> {
let armed = std::mem::replace(
&mut *self
.next_error
.lock()
.expect("mock next_error mutex poisoned"),
None,
);
if let Some(err) = armed {
return Err(err);
}
let key = (repo.as_str().to_string(), filename.to_string());
if let Some(cached) = self
.cache
.lock()
.expect("mock cache mutex poisoned")
.get(&key)
.cloned()
{
return Ok(cached);
}
use std::io::Write as _;
use tempfile::NamedTempFile;
let mut tmp = NamedTempFile::new()
.map_err(|e| LlamaError::ModelDownload(format!("tempfile create: {e}")))?;
tmp.write_all(b"GGUF\x00\x00\x00\x03")
.map_err(|e| LlamaError::ModelDownload(format!("tempfile write: {e}")))?;
let (_file, path) = tmp
.keep()
.map_err(|e| LlamaError::ModelDownload(format!("tempfile keep: {e}")))?;
self.cache
.lock()
.expect("mock cache mutex poisoned")
.insert(key, path.clone());
Ok(path)
}
fn list_repo_files(&self, repo: &HfRepo) -> Result<Vec<String>, LlamaError> {
let configured = self
.list_files
.lock()
.expect("mock list_files mutex poisoned")
.get(repo.as_str())
.cloned();
Ok(configured.unwrap_or_default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::LlamaError;
use std::path::PathBuf;
#[test]
fn mock_caches_path_per_repo_filename() {
let mock = MockHfDownloader::default();
let repo = HfRepo::new("TheBloke/Foo").expect("valid repo id");
let first = mock.get(&repo, "model.Q4_K_M.gguf").expect("first get");
let second = mock.get(&repo, "model.Q4_K_M.gguf").expect("second get");
assert_eq!(first, second, "same (repo, filename) must return same path");
assert!(
first.exists(),
"cached path must still exist on disk: {}",
first.display()
);
assert_eq!(
std::fs::read(&first).expect("read back blob"),
b"GGUF\x00\x00\x00\x03",
"blob must contain valid GGUF v3 magic"
);
}
#[test]
fn mock_returns_injected_error() {
let mock = MockHfDownloader::default()
.with_next_error(LlamaError::ModelDownload("injected 404".into()));
let repo = HfRepo::new("TheBloke/Foo").expect("valid repo id");
let err = mock
.get(&repo, "model.gguf")
.expect_err("injected error must propagate");
match err {
LlamaError::ModelDownload(msg) => {
assert_eq!(msg, "injected 404", "error message preserved");
}
other => panic!("expected ModelDownload, got {other:?}"),
}
let recovered = mock
.get(&repo, "model.gguf")
.expect("post-injection get must succeed");
assert!(recovered.exists(), "recovered path must exist on disk");
}
#[test]
fn mock_list_files_returns_configured() {
let mock = MockHfDownloader::default()
.with_files("TheBloke/Foo", vec!["a.gguf".into(), "b.gguf".into()])
.with_files("TheBloke/Empty", vec![]);
let configured = mock
.list_repo_files(&HfRepo::new("TheBloke/Foo").expect("valid"))
.expect("list configured");
assert_eq!(configured, vec!["a.gguf".to_string(), "b.gguf".to_string()]);
let empty = mock
.list_repo_files(&HfRepo::new("TheBloke/Empty").expect("valid"))
.expect("list empty");
assert!(empty.is_empty(), "explicit empty must be empty");
let never = mock
.list_repo_files(&HfRepo::new("TheBloke/Other").expect("valid"))
.expect("list never-configured");
assert!(
never.is_empty(),
"never-configured repo must fall through to default empty"
);
let _ = PathBuf::new();
}
#[cfg(feature = "hf-hub")]
#[test]
#[ignore]
fn real_downloader_logs_start_and_end() {
if std::env::var("LLAMA_CRAB_RUN_HF_INTEGRATION").is_err() {
eprintln!("skipping: set LLAMA_CRAB_RUN_HF_INTEGRATION=1 to enable");
return;
}
let buf: Arc<std::sync::Mutex<Vec<u8>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let writer = TracingBufWriter(buf.clone());
let subscriber = tracing_subscriber::fmt()
.with_writer(writer)
.with_max_level(tracing::Level::INFO)
.with_target(false)
.with_ansi(false)
.finish();
tracing::subscriber::with_default(subscriber, || {
let dl = RealHfDownloader::new().expect("downloader init");
let repo =
HfRepo::new("TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF").expect("valid repo id");
dl.get(&repo, "tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf")
.expect("download must succeed");
});
let captured = {
let guard = buf.lock().expect("buf lock");
String::from_utf8(guard.clone()).expect("captured bytes are utf-8")
};
assert!(
captured.contains("downloading from Hugging Face"),
"start log missing in captured output: {captured}"
);
assert!(
captured.contains("downloaded from Hugging Face"),
"end log missing in captured output: {captured}"
);
assert!(
captured.contains("repo=") && captured.contains("filename="),
"expected fields 'repo=' and 'filename=' in start log: {captured}"
);
assert!(
captured.contains("size_bytes=") && captured.contains("elapsed_ms="),
"expected fields 'size_bytes=' and 'elapsed_ms=' in end log: {captured}"
);
assert!(
!captured.contains("HF_TOKEN") && !captured.contains("token="),
"token leaked into logs: {captured}"
);
assert!(
!captured.contains("HF_ENDPOINT") && !captured.contains("endpoint="),
"endpoint leaked into logs: {captured}"
);
assert!(
!captured.contains("https://"),
"URL leaked into logs: {captured}"
);
}
#[cfg(feature = "hf-hub")]
struct TracingBufWriter(Arc<std::sync::Mutex<Vec<u8>>>);
#[cfg(feature = "hf-hub")]
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TracingBufWriter {
type Writer = TracingBufGuard<'a>;
fn make_writer(&'a self) -> Self::Writer {
TracingBufGuard(self.0.lock().expect("buf lock"))
}
}
#[cfg(feature = "hf-hub")]
struct TracingBufGuard<'a>(std::sync::MutexGuard<'a, Vec<u8>>);
#[cfg(feature = "hf-hub")]
impl std::io::Write for TracingBufGuard<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
}
#[derive(Debug)]
struct DisabledHfDownloader;
impl HfDownloader for DisabledHfDownloader {
fn get(&self, _repo: &HfRepo, _filename: &str) -> Result<PathBuf, LlamaError> {
Err(LlamaError::ModelDownload(
"hf-hub feature is disabled \u{2014} rebuild with --features hf-hub".into(),
))
}
}
pub(crate) fn default_downloader() -> Result<Arc<dyn HfDownloader>, LlamaError> {
#[cfg(feature = "hf-hub")]
{
Ok(Arc::new(RealHfDownloader::new()?))
}
#[cfg(not(feature = "hf-hub"))]
{
Ok(Arc::new(DisabledHfDownloader))
}
}
#[cfg(feature = "hf-hub")]
#[derive(Debug, Clone, Default)]
pub struct RealHfDownloader {
cache_dir: Option<PathBuf>,
token: Option<String>,
endpoint: Option<String>,
revision: Option<String>,
}
#[cfg(feature = "hf-hub")]
impl RealHfDownloader {
pub fn new() -> Result<Self, LlamaError> {
Ok(Self {
cache_dir: None,
token: std::env::var("HF_TOKEN").ok(),
endpoint: std::env::var("HF_ENDPOINT").ok(),
revision: None,
})
}
#[must_use]
pub fn with_cache_dir(mut self, dir: PathBuf) -> Self {
self.cache_dir = Some(dir);
self
}
#[must_use]
pub fn with_endpoint(mut self, ep: String) -> Self {
self.endpoint = Some(ep);
self
}
#[must_use]
pub fn with_revision(mut self, rev: String) -> Self {
self.revision = Some(rev);
self
}
fn build_repo(&self, repo: &HfRepo) -> hf_hub::Repo {
let repo_id = repo.repo_id().to_string();
match &self.revision {
Some(rev) => hf_hub::Repo::with_revision(repo_id, hf_hub::RepoType::Model, rev.clone()),
None => hf_hub::Repo::new(repo_id, hf_hub::RepoType::Model),
}
}
fn build_api(&self) -> Result<hf_hub::api::sync::Api, LlamaError> {
let mut builder = hf_hub::api::sync::ApiBuilder::from_env();
if let Some(dir) = &self.cache_dir {
builder = builder.with_cache_dir(dir.clone());
}
if let Some(tok) = &self.token {
builder = builder.with_token(Some(tok.clone()));
}
if let Some(ep) = &self.endpoint {
builder = builder.with_endpoint(ep.clone());
}
builder
.build()
.map_err(|e| LlamaError::ModelDownload(format!("api build: {e}")))
}
}
#[cfg(feature = "hf-hub")]
impl HfDownloader for RealHfDownloader {
fn get(&self, repo: &HfRepo, filename: &str) -> Result<PathBuf, LlamaError> {
let started = std::time::Instant::now();
tracing::info!(
repo = repo.repo_id(),
filename,
"downloading from Hugging Face"
);
let result: Result<PathBuf, LlamaError> = (|| {
let api = self.build_api()?;
let api_repo = api.repo(self.build_repo(repo));
api_repo
.get(filename)
.map_err(|e| LlamaError::ModelDownload(format!("download: {e}")))
})();
match &result {
Ok(path) => {
let size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
tracing::info!(
repo = repo.repo_id(),
filename,
size_bytes,
elapsed_ms = started.elapsed().as_millis() as u64,
"downloaded from Hugging Face"
);
}
Err(_) => {
tracing::warn!(
repo = repo.repo_id(),
filename,
elapsed_ms = started.elapsed().as_millis() as u64,
"Hugging Face download failed"
);
}
}
result
}
fn list_repo_files(&self, repo: &HfRepo) -> Result<Vec<String>, LlamaError> {
let api = self.build_api()?;
let api_repo = api.repo(self.build_repo(repo));
let info = api_repo
.info()
.map_err(|e| LlamaError::ModelDownload(format!("repo info: {e}")))?;
Ok(info
.siblings
.iter()
.map(|s| s.rfilename.clone())
.filter(|n| n.ends_with(".gguf"))
.collect())
}
}
#[cfg(all(test, feature = "hf-hub"))]
mod real_tests {
use super::*;
#[test]
fn real_downloader_constructs_with_no_env() {
let result = RealHfDownloader::new();
assert!(
result.is_ok(),
"RealHfDownloader::new must succeed (env reads are .ok())"
);
}
#[test]
#[ignore]
fn real_downloader_fetches_tinyllama() {
if std::env::var("LLAMA_CRAB_RUN_HF_INTEGRATION")
.ok()
.as_deref()
!= Some("1")
{
panic!("LLAMA_CRAB_RUN_HF_INTEGRATION must be set to 1 to run this network test");
}
let dl = RealHfDownloader::new().expect("downloader init");
let repo = HfRepo::new("TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF").expect("valid repo id");
let path = dl
.get(&repo, "tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf")
.expect("download must succeed");
assert!(
path.exists(),
"downloaded file must exist on disk: {}",
path.display()
);
let meta = std::fs::metadata(&path).expect("stat downloaded file");
assert!(meta.len() > 0, "downloaded file must be > 0 bytes");
}
}