use crate::CandelabraError;
use futures::StreamExt;
use hf_hub::{
api::sync::{ApiBuilder, ApiRepo},
Cache, Repo,
};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Instant;
use tokenizers::Tokenizer;
use tokio::sync::mpsc::Sender;
const DEFAULT_REVISION: &str = "main";
const TOKENIZER_FILENAME: &str = "tokenizer.json";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DownloadProgress {
pub downloaded_bytes: u64,
pub total_bytes: u64,
pub percentage: f32,
pub speed_bytes_per_sec: f64,
pub filename: String,
}
struct RemoteMetadata {
commit_hash: String,
total_bytes: u64,
}
pub fn check_model_cached(repo_id: &str, filename: &str) -> bool {
cached_model_path(&Cache::default(), repo_id, filename).is_some()
}
pub fn download_model(repo_id: &str, filename: &str) -> Result<PathBuf, CandelabraError> {
let api = build_hf_api()?;
api.model(repo_id.to_string())
.get(filename)
.map_err(|e| CandelabraError::Download(format!("Failed to download model file: {}", e)))
}
pub fn download_tokenizer(repo_id: &str) -> Result<PathBuf, CandelabraError> {
download_model(repo_id, TOKENIZER_FILENAME)
}
pub fn load_tokenizer<P: AsRef<Path>>(path: P) -> Result<Tokenizer, CandelabraError> {
Tokenizer::from_file(path.as_ref())
.map_err(|e| CandelabraError::Tokenizer(format!("Failed to load tokenizer: {}", e)))
}
pub fn load_tokenizer_from_repo(repo_id: &str) -> Result<Tokenizer, CandelabraError> {
let tokenizer_path = download_tokenizer(repo_id)?;
load_tokenizer(tokenizer_path)
}
pub async fn download_model_with_progress<F>(
repo_id: &str,
filename: &str,
mut on_progress: F,
) -> Result<PathBuf, CandelabraError>
where
F: FnMut(DownloadProgress) + Send,
{
if let Some(cached_path) = Cache::default().model(repo_id.to_string()).get(filename) {
emit_cached_progress(filename, &cached_path, &mut on_progress)?;
return Ok(cached_path);
}
let api = build_hf_api()?;
let repo = api.model(repo_id.to_string());
let metadata = fetch_remote_metadata(&repo, filename).await?;
let file_path = get_snapshot_file_path(repo_id, &metadata.commit_hash, filename);
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent)?;
}
let temp_path = temp_download_path(&file_path);
if temp_path.exists() {
let _ = std::fs::remove_file(&temp_path);
}
let client = build_http_client()?;
let url = repo.url(filename);
let mut request = client.get(&url);
if let Some(token) = Cache::default().token() {
request = request.bearer_auth(token);
}
let response = request
.send()
.await
.map_err(|e| CandelabraError::Download(format!("Failed to start download: {}", e)))?;
if !response.status().is_success() {
return Err(CandelabraError::Download(format!(
"Download failed with status: {}",
response.status()
)));
}
let total_bytes = response.content_length().unwrap_or(metadata.total_bytes);
let mut file = std::fs::File::create(&temp_path)?;
let mut downloaded = 0_u64;
let mut stream = response.bytes_stream();
let mut speed_samples: VecDeque<(Instant, u64)> = VecDeque::new();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result
.map_err(|e| CandelabraError::Download(format!("Download stream error: {}", e)))?;
file.write_all(&chunk)?;
downloaded += chunk.len() as u64;
let now = Instant::now();
speed_samples.push_back((now, downloaded));
while let Some((time, _)) = speed_samples.front() {
if now.duration_since(*time).as_secs_f64() > 1.0 {
speed_samples.pop_front();
} else {
break;
}
}
on_progress(DownloadProgress {
downloaded_bytes: downloaded,
total_bytes,
percentage: percentage(downloaded, total_bytes),
speed_bytes_per_sec: rolling_speed(&speed_samples, downloaded, now),
filename: filename.to_string(),
});
}
file.flush()?;
drop(file);
std::fs::rename(&temp_path, &file_path)?;
create_cache_ref(repo_id, &metadata.commit_hash)?;
Ok(file_path)
}
pub async fn download_model_with_channel(
repo_id: &str,
filename: &str,
progress_tx: Sender<DownloadProgress>,
) -> Result<PathBuf, CandelabraError> {
download_model_with_progress(repo_id, filename, move |progress| {
let _ = progress_tx.blocking_send(progress);
})
.await
}
pub async fn download_tokenizer_with_progress<F>(
repo_id: &str,
on_progress: F,
) -> Result<PathBuf, CandelabraError>
where
F: FnMut(DownloadProgress) + Send,
{
download_model_with_progress(repo_id, TOKENIZER_FILENAME, on_progress).await
}
pub async fn download_tokenizer_with_channel(
repo_id: &str,
progress_tx: Sender<DownloadProgress>,
) -> Result<PathBuf, CandelabraError> {
download_model_with_channel(repo_id, TOKENIZER_FILENAME, progress_tx).await
}
fn build_hf_api() -> Result<hf_hub::api::sync::Api, CandelabraError> {
ApiBuilder::new()
.with_progress(false)
.build()
.map_err(|e| CandelabraError::Download(format!("Failed to create HF API: {}", e)))
}
fn build_http_client() -> Result<reqwest::Client, CandelabraError> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.map_err(|e| CandelabraError::Download(format!("Failed to create HTTP client: {}", e)))
}
async fn fetch_remote_metadata(
repo: &ApiRepo,
filename: &str,
) -> Result<RemoteMetadata, CandelabraError> {
let client = build_http_client()?;
let mut request = client.head(repo.url(filename));
if let Some(token) = Cache::default().token() {
request = request.bearer_auth(token);
}
let head_response = request.send().await.ok();
let total_bytes = head_response
.as_ref()
.and_then(reqwest::Response::content_length)
.unwrap_or(0);
let commit_hash = head_response
.as_ref()
.and_then(|response| response.headers().get("x-repo-commit"))
.and_then(|value| value.to_str().ok())
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
repo.info()
.map(|info| info.sha)
.unwrap_or_else(|_| DEFAULT_REVISION.to_string())
});
Ok(RemoteMetadata {
commit_hash,
total_bytes,
})
}
fn emit_cached_progress<F>(
filename: &str,
path: &Path,
on_progress: &mut F,
) -> Result<(), CandelabraError>
where
F: FnMut(DownloadProgress),
{
let total_bytes = std::fs::metadata(path)?.len();
on_progress(DownloadProgress {
downloaded_bytes: total_bytes,
total_bytes,
percentage: 100.0,
speed_bytes_per_sec: 0.0,
filename: filename.to_string(),
});
Ok(())
}
fn cached_model_path(cache: &Cache, repo_id: &str, filename: &str) -> Option<PathBuf> {
cache.model(repo_id.to_string()).get(filename)
}
fn get_snapshot_file_path(repo_id: &str, commit_hash: &str, filename: &str) -> PathBuf {
let cache_root = Cache::default().path().clone();
let repo = Repo::model(repo_id.to_string());
cache_root
.join(repo.folder_name())
.join("snapshots")
.join(commit_hash)
.join(filename)
}
fn create_cache_ref(repo_id: &str, commit_hash: &str) -> Result<(), CandelabraError> {
Cache::default()
.model(repo_id.to_string())
.create_ref(commit_hash)
.map_err(|e| CandelabraError::Download(format!("Failed to update cache ref: {}", e)))
}
fn temp_download_path(file_path: &Path) -> PathBuf {
let extension = file_path
.extension()
.and_then(|value| value.to_str())
.map(|value| format!("{value}.tmp"))
.unwrap_or_else(|| "tmp".to_string());
file_path.with_extension(extension)
}
fn percentage(downloaded: u64, total_bytes: u64) -> f32 {
if total_bytes == 0 {
return 0.0;
}
(downloaded as f32 / total_bytes as f32) * 100.0
}
fn rolling_speed(samples: &VecDeque<(Instant, u64)>, downloaded: u64, now: Instant) -> f64 {
if samples.len() < 2 {
return 0.0;
}
let Some((oldest_time, oldest_bytes)) = samples.front() else {
return 0.0;
};
let elapsed = now.duration_since(*oldest_time).as_secs_f64();
if elapsed <= 0.0 {
return 0.0;
}
(downloaded - *oldest_bytes) as f64 / elapsed
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time before unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("candelabra-test-{unique}"));
fs::create_dir_all(&path).expect("failed to create temp dir");
Self { path }
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn check_model_cached_uses_hf_layout() {
let temp_dir = TempDir::new();
let cache_root = temp_dir.path.join("hub");
fs::create_dir_all(&cache_root).expect("failed to create cache root");
let cache = Cache::new(cache_root);
let repo_id = "org/model";
let filename = "weights.gguf";
let commit_hash = "abc123";
let snapshot_file = cache
.path()
.join(Repo::model(repo_id.to_string()).folder_name())
.join("snapshots")
.join(commit_hash)
.join(filename);
fs::create_dir_all(snapshot_file.parent().expect("snapshot parent"))
.expect("failed to create snapshot dir");
fs::write(&snapshot_file, b"model").expect("failed to write snapshot file");
cache
.model(repo_id.to_string())
.create_ref(commit_hash)
.expect("failed to create cache ref");
assert!(cached_model_path(&cache, repo_id, filename).is_some());
}
#[test]
fn rolling_speed_is_zero_without_enough_samples() {
let samples = VecDeque::from([(Instant::now(), 128)]);
assert_eq!(rolling_speed(&samples, 128, Instant::now()), 0.0);
}
#[test]
fn percentage_handles_unknown_sizes() {
assert_eq!(percentage(1024, 0), 0.0);
}
}