use anyhow::{Context, Result, bail};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use super::plan::RemoteFile;
use super::{DownloadError, HOST};
const CHUNK: usize = 1024 * 1024;
fn agent() -> &'static ureq::Agent {
static POOL: std::sync::OnceLock<ureq::Agent> = std::sync::OnceLock::new();
POOL.get_or_init(|| {
ureq::Agent::config_builder()
.timeout_connect(Some(std::time::Duration::from_secs(30)))
.build()
.into()
})
}
pub fn token() -> Option<String> {
let env = ["HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"].map(|v| std::env::var(v).ok());
let file = std::env::var_os("HOME")
.and_then(|h| std::fs::read_to_string(Path::new(&h).join(".cache/huggingface/token")).ok());
pick_token(&env, file.as_deref())
}
pub(super) fn pick_token(env: &[Option<String>], file: Option<&str>) -> Option<String> {
for v in env.iter().flatten() {
if !v.trim().is_empty() {
return Some(v.trim().to_string());
}
}
file.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
}
pub(super) fn classify(repo: &str, status: u16, had_token: bool) -> DownloadError {
match status {
401 | 403 => DownloadError::Gated {
repo: repo.into(),
had_token,
},
404 => DownloadError::NotFound { repo: repo.into() },
429 => DownloadError::RateLimited,
s => DownloadError::Http {
repo: repo.into(),
status: s,
},
}
}
fn get(
url: &str,
token: Option<&str>,
range_from: Option<u64>,
) -> Result<ureq::http::Response<ureq::Body>, (u16, anyhow::Error)> {
let mut req = agent().get(url).header("User-Agent", super::AGENT);
if let Some(t) = token {
req = req.header("Authorization", &format!("Bearer {t}"));
}
if let Some(from) = range_from {
req = req.header("Range", &format!("bytes={from}-"));
}
match req.call() {
Ok(r) => Ok(r),
Err(ureq::Error::StatusCode(code)) => Err((code, anyhow::anyhow!("HTTP {code}"))),
Err(e) => Err((0, anyhow::Error::new(e))),
}
}
fn get_or_anon(
url: &str,
token: Option<&str>,
range_from: Option<u64>,
) -> Result<ureq::http::Response<ureq::Body>, (u16, anyhow::Error)> {
match get(url, token, range_from) {
Err((s @ (401 | 403), e)) if token.is_some() => match get(url, None, range_from) {
Ok(r) => Ok(r),
Err(_) => Err((s, e)),
},
other => other,
}
}
pub fn repo_info(
repo: &str,
tok: Option<&str>,
) -> Result<(String, Vec<RemoteFile>), DownloadError> {
let had = tok.is_some();
let url = format!("{HOST}/api/models/{repo}");
let body = match get_or_anon(&url, tok, None) {
Ok(r) => r
.into_body()
.read_to_string()
.map_err(|e| DownloadError::Io(e.to_string()))?,
Err((0, e)) => return Err(DownloadError::Offline(e.to_string())),
Err((s, _)) => return Err(classify(repo, s, had)),
};
let doc: serde_json::Value =
serde_json::from_str(&body).map_err(|e| DownloadError::Io(e.to_string()))?;
let sha = doc
.get("sha")
.and_then(|s| s.as_str())
.ok_or_else(|| DownloadError::Io("model info has no sha".into()))?
.to_string();
let files = doc
.get("siblings")
.and_then(|s| s.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| e.get("rfilename").and_then(|n| n.as_str()))
.map(|name| RemoteFile {
name: name.to_string(),
size: None,
})
.collect()
})
.unwrap_or_default();
Ok((sha, files))
}
pub fn sizes(repo: &str, revision: &str, tok: Option<&str>) -> Vec<(String, u64)> {
let url = format!("{HOST}/api/models/{repo}/tree/{revision}?recursive=1");
let Ok(r) = get_or_anon(&url, tok, None) else {
return Vec::new();
};
let Ok(body) = r.into_body().read_to_string() else {
return Vec::new();
};
let Ok(doc) = serde_json::from_str::<serde_json::Value>(&body) else {
return Vec::new();
};
doc.as_array()
.map(|arr| {
arr.iter()
.filter_map(|e| {
let path = e.get("path")?.as_str()?.to_string();
let size = e
.get("lfs")
.and_then(|l| l.get("size"))
.and_then(|s| s.as_u64())
.or_else(|| e.get("size").and_then(|s| s.as_u64()))?;
Some((path, size))
})
.collect()
})
.unwrap_or_default()
}
pub fn fetch_file(
repo: &str,
revision: &str,
name: &str,
dest: &Path,
tok: Option<&str>,
cancel: &AtomicBool,
on_bytes: &mut dyn FnMut(u64),
) -> Result<bool, DownloadError> {
let had = tok.is_some();
let part = part_path(dest);
let have = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
let url = format!("{HOST}/{repo}/resolve/{revision}/{name}");
let resp = match get_or_anon(&url, tok, (have > 0).then_some(have)) {
Ok(r) => r,
Err((0, e)) => return Err(DownloadError::Offline(e.to_string())),
Err((s, _)) => return Err(classify(repo, s, had)),
};
let resuming = resp.status().as_u16() == 206;
let mut written = if resuming { have } else { 0 };
on_bytes(written);
if let Some(parent) = part.parent() {
std::fs::create_dir_all(parent).map_err(|e| DownloadError::Io(e.to_string()))?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.append(resuming)
.truncate(!resuming)
.open(&part)
.map_err(|e| DownloadError::Io(e.to_string()))?;
let mut reader = resp.into_body().into_reader();
let mut buf = vec![0u8; CHUNK];
loop {
if cancel.load(Ordering::Relaxed) {
file.flush().ok();
return Ok(false);
}
let n = reader
.read(&mut buf)
.map_err(|e| DownloadError::Io(e.to_string()))?;
if n == 0 {
break;
}
file.write_all(&buf[..n]).map_err(write_error)?;
written += n as u64;
on_bytes(written);
}
file.flush().map_err(|e| DownloadError::Io(e.to_string()))?;
drop(file);
std::fs::rename(&part, dest).map_err(|e| DownloadError::Io(e.to_string()))?;
Ok(true)
}
pub fn part_path(dest: &Path) -> PathBuf {
dest.with_file_name(format!(
"{}.part",
dest.file_name().unwrap_or_default().to_string_lossy()
))
}
pub(super) fn write_error(e: std::io::Error) -> DownloadError {
if e.kind() == std::io::ErrorKind::StorageFull {
DownloadError::DiskFull
} else {
DownloadError::Io(e.to_string())
}
}
pub fn free_bytes(path: &Path) -> Option<u64> {
let mut cur = Some(path);
while let Some(p) = cur {
if let Some(n) = statvfs_avail(p) {
return Some(n);
}
cur = p.parent();
}
None
}
#[cfg(not(unix))]
fn statvfs_avail(_path: &Path) -> Option<u64> {
None
}
pub fn disk_usage(path: &Path) -> Option<(u64, u64)> {
let mut cur = Some(path);
while let Some(p) = cur {
if let Some(u) = statvfs_usage(p) {
return Some(u);
}
cur = p.parent();
}
None
}
#[cfg(not(unix))]
fn statvfs_usage(_path: &Path) -> Option<(u64, u64)> {
None
}
#[cfg(unix)]
fn statvfs_usage(path: &Path) -> Option<(u64, u64)> {
let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).ok()?;
unsafe {
let mut stat: libc::statvfs = std::mem::zeroed();
if libc::statvfs(c.as_ptr(), &mut stat) != 0 {
return None;
}
#[allow(clippy::unnecessary_cast)]
let free = stat.f_bavail as u64 * stat.f_frsize as u64;
#[allow(clippy::unnecessary_cast)]
let total = stat.f_blocks as u64 * stat.f_frsize as u64;
(total > 0).then_some((free, total))
}
}
#[cfg(unix)]
fn statvfs_avail(path: &Path) -> Option<u64> {
let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).ok()?;
unsafe {
let mut stat: libc::statvfs = std::mem::zeroed();
if libc::statvfs(c.as_ptr(), &mut stat) != 0 {
return None;
}
#[allow(clippy::unnecessary_cast)]
Some(stat.f_bavail as u64 * stat.f_frsize as u64)
}
}
pub fn repo_dir(cache_root: &Path, repo: &str) -> PathBuf {
cache_root.join(format!("models--{}", repo.replace('/', "--")))
}
pub fn local_revision(cache_root: &Path, repo: &str) -> Option<String> {
let p = repo_dir(cache_root, repo).join("refs/main");
let s = std::fs::read_to_string(p).ok()?;
let s = s.trim().to_string();
(!s.is_empty()).then_some(s)
}
pub fn publish(cache_root: &Path, repo: &str, revision: &str) -> Result<()> {
let dir = repo_dir(cache_root, repo);
let snapshot = dir.join("snapshots").join(revision);
if !snapshot.join("config.json").exists() && !snapshot.join("params.json").exists() {
bail!("refusing to publish {repo}@{revision}: no config.json or params.json");
}
let has_shard = std::fs::read_dir(&snapshot)
.map(|rd| {
rd.filter_map(|e| e.ok()).any(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.ends_with(".safetensors"))
})
})
.unwrap_or(false);
if !has_shard {
bail!("refusing to publish {repo}@{revision}: no .safetensors shard on disk");
}
let refs = dir.join("refs");
std::fs::create_dir_all(&refs).context("creating refs/")?;
let tmp = refs.join("main.tmp");
std::fs::write(&tmp, revision)?;
std::fs::rename(&tmp, refs.join("main"))?;
Ok(())
}