use std::path::PathBuf;
use serde_json;
use thiserror::Error;
use tracing::{debug, info, warn};
use crate::progress::ProgressReporter;
const DISK_REQUIREMENT_QWEN35MOE_BYTES: u64 = 150 * 1024 * 1024 * 1024;
const DISK_REQUIREMENT_QWEN35_BYTES: u64 = 55 * 1024 * 1024 * 1024;
const DISK_REQUIREMENT_DEFAULT_BYTES: u64 = 100 * 1024 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelClass {
Qwen35Moe,
Qwen35Dense,
Other,
}
impl ModelClass {
pub fn from_repo_id(repo_id: &str) -> Self {
let lower = repo_id.to_lowercase();
if lower.contains("-a3b") || lower.contains("-moe") || lower.contains("35b-a") {
return ModelClass::Qwen35Moe;
}
if (lower.contains("qwen3") || lower.contains("qwen35")) && lower.contains("27b") {
return ModelClass::Qwen35Dense;
}
ModelClass::Other
}
pub fn min_free_bytes(self) -> u64 {
match self {
ModelClass::Qwen35Moe => DISK_REQUIREMENT_QWEN35MOE_BYTES,
ModelClass::Qwen35Dense => DISK_REQUIREMENT_QWEN35_BYTES,
ModelClass::Other => DISK_REQUIREMENT_DEFAULT_BYTES,
}
}
pub fn label(self) -> &'static str {
match self {
ModelClass::Qwen35Moe => "Qwen3.5-MoE 35B",
ModelClass::Qwen35Dense => "Qwen3.5 27B dense",
ModelClass::Other => "model",
}
}
}
#[derive(Error, Debug)]
pub enum DownloadError {
#[error(
"Failed to download from HuggingFace Hub: {reason}\n\
\n\
Troubleshooting:\n\
- Check your network connection\n\
- For gated models, ensure you have accepted the license at huggingface.co\n\
- Set HF_TOKEN env var or run: huggingface-cli login\n\
- Install hf CLI as fallback: pip install huggingface_hub[cli]"
)]
DownloadFailed { reason: String },
#[error(
"Authentication failed for repository '{repo}'.\n\
\n\
This model may be gated or private. To access it:\n\
1. Accept the model license at https://huggingface.co/{repo}\n\
2. Set your token: export HF_TOKEN=hf_xxxx\n\
Or create ~/.huggingface/token with your token\n\
3. Alternatively, run: huggingface-cli login"
)]
AuthError { repo: String },
#[error(
"Repository not found: {repo}\n\
\n\
Check that the repository ID is correct (format: org/model-name).\n\
Example: google/gemma-3-27b"
)]
RepoNotFound { repo: String },
#[error(
"No model files found in repository '{repo}'.\n\
The repository exists but contains no safetensors files."
)]
NoModelFiles { repo: String },
#[error(
"hf CLI fallback also failed: {reason}\n\
\n\
Install the HuggingFace CLI: pip install huggingface_hub[cli]\n\
Then try again."
)]
CliFallbackFailed { reason: String },
#[error(
"{label} requires \u{2265}{required_gb} GB free in {path}; found {found_gb} GB. \
Free space or change --cache-dir."
)]
InsufficientDisk {
label: String,
required_gb: u64,
found_gb: u64,
path: String,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
const REQUIRED_FILES: &[&str] = &["config.json"];
const OPTIONAL_FILES: &[&str] = &[
"tokenizer.json",
"tokenizer_config.json",
"special_tokens_map.json",
"tokenizer.model",
"generation_config.json",
];
pub fn check_disk_preflight(
repo_id: &str,
target_path: &std::path::Path,
available_bytes_override: Option<u64>,
) -> Result<(), DownloadError> {
let class = ModelClass::from_repo_id(repo_id);
let required = class.min_free_bytes();
let available = match available_bytes_override {
Some(v) => v,
None => get_available_space_for_path(target_path),
};
debug!(
repo = %repo_id,
class = ?class,
required_gb = required / (1024 * 1024 * 1024),
available_gb = available / (1024 * 1024 * 1024),
"Disk preflight check"
);
if available < required {
let path_str = target_path.display().to_string();
return Err(DownloadError::InsufficientDisk {
label: class.label().to_string(),
required_gb: required / (1024 * 1024 * 1024),
found_gb: available / (1024 * 1024 * 1024),
path: path_str,
});
}
Ok(())
}
fn get_available_space_for_path(path: &std::path::Path) -> u64 {
let existing = {
let mut p = path.to_path_buf();
loop {
if p.exists() {
break p;
}
match p.parent() {
Some(parent) => p = parent.to_path_buf(),
None => break std::path::PathBuf::from("/"),
}
}
};
use sysinfo::Disks;
let disks = Disks::new_with_refreshed_list();
let mut best: Option<(usize, u64)> = None;
for disk in disks.list() {
let mount = disk.mount_point();
if existing.starts_with(mount) {
let len = mount.as_os_str().len();
match best {
Some((prev, _)) if len > prev => best = Some((len, disk.available_space())),
None => best = Some((len, disk.available_space())),
_ => {}
}
}
}
best.map(|(_, space)| space).unwrap_or(0)
}
pub fn download_model(
repo_id: &str,
progress: &ProgressReporter,
) -> Result<PathBuf, DownloadError> {
info!(repo = %repo_id, "Downloading model from HuggingFace Hub");
let cache_dir = resolve_hf_cache_dir();
check_disk_preflight(repo_id, &cache_dir, None)?;
match download_via_hf_hub(repo_id, progress) {
Ok(path) => Ok(path),
Err(e) => {
warn!(
"hf-hub crate download failed: {}. Trying hf CLI fallback...",
e
);
match download_via_hf_cli(repo_id, progress) {
Ok(path) => Ok(path),
Err(cli_err) => {
Err(DownloadError::DownloadFailed {
reason: format!("hf-hub crate: {}. hf CLI: {}", e, cli_err),
})
}
}
}
}
}
fn download_via_hf_hub(
repo_id: &str,
progress: &ProgressReporter,
) -> Result<PathBuf, DownloadError> {
use hf_hub::api::sync::ApiBuilder;
let token = resolve_auth_token();
debug!(has_token = token.is_some(), "Auth token resolution");
let mut builder = ApiBuilder::new().with_progress(true);
if let Some(t) = token {
builder = builder.with_token(Some(t));
}
let api = builder.build().map_err(|e| DownloadError::DownloadFailed {
reason: format!("Failed to initialize HuggingFace API client: {}", e),
})?;
let repo = api.model(repo_id.to_string());
let repo_info = repo.info().map_err(|e| {
let err_str = format!("{}", e);
if err_str.contains("401") || err_str.contains("403") || err_str.contains("auth") {
DownloadError::AuthError {
repo: repo_id.to_string(),
}
} else if err_str.contains("404") || err_str.contains("not found") {
DownloadError::RepoNotFound {
repo: repo_id.to_string(),
}
} else {
DownloadError::DownloadFailed {
reason: format!("Failed to get repository info: {}", e),
}
}
})?;
let all_files: Vec<String> = repo_info
.siblings
.iter()
.map(|s| s.rfilename.clone())
.collect();
debug!(
file_count = all_files.len(),
"Repository file listing retrieved"
);
let safetensors_files: Vec<&String> = all_files
.iter()
.filter(|f| f.ends_with(".safetensors"))
.collect();
let index_file: Option<&String> = all_files
.iter()
.find(|f| f.as_str() == "model.safetensors.index.json");
if safetensors_files.is_empty() {
return Err(DownloadError::NoModelFiles {
repo: repo_id.to_string(),
});
}
let mut files_to_download: Vec<&str> = Vec::new();
for required in REQUIRED_FILES {
if all_files.iter().any(|f| f.as_str() == *required) {
files_to_download.push(required);
} else {
return Err(DownloadError::DownloadFailed {
reason: format!("Required file '{}' not found in repository", required),
});
}
}
for optional in OPTIONAL_FILES {
if all_files.iter().any(|f| f.as_str() == *optional) {
files_to_download.push(optional);
}
}
let mut needed_shards: Option<Vec<String>> = None;
if let Some(idx) = index_file {
files_to_download.push(idx.as_str());
debug!("Downloading index file to determine required shards");
let idx_path = repo
.get(idx.as_str())
.map_err(|e| DownloadError::DownloadFailed {
reason: format!("Failed to download index file: {}", e),
})?;
if let Ok(content) = std::fs::read_to_string(&idx_path) {
if let Ok(index) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(weight_map) = index.get("weight_map").and_then(|v| v.as_object()) {
let mut shard_names: Vec<String> = weight_map
.values()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
shard_names.sort();
shard_names.dedup();
info!(
total_safetensors = safetensors_files.len(),
needed = shard_names.len(),
"Index specifies {} of {} safetensors files",
shard_names.len(),
safetensors_files.len(),
);
needed_shards = Some(shard_names);
}
}
}
}
match &needed_shards {
Some(shards) => {
for shard in shards {
files_to_download.push(shard.as_str());
}
}
None => {
for sf in &safetensors_files {
files_to_download.push(sf.as_str());
}
}
}
let total_files = files_to_download.len();
let pb = progress.bar(total_files as u64, "Downloading model files");
let mut downloaded_path: Option<PathBuf> = None;
for filename in &files_to_download {
debug!(file = %filename, "Downloading");
let local_path = repo.get(filename).map_err(|e| {
let err_str = format!("{}", e);
if err_str.contains("401") || err_str.contains("403") {
DownloadError::AuthError {
repo: repo_id.to_string(),
}
} else {
DownloadError::DownloadFailed {
reason: format!("Failed to download '{}': {}", filename, e),
}
}
})?;
if downloaded_path.is_none() {
if let Some(parent) = local_path.parent() {
downloaded_path = Some(parent.to_path_buf());
}
}
pb.inc(1);
}
pb.finish_with_message(format!("Downloaded {} files", total_files));
let model_dir = downloaded_path.ok_or_else(|| DownloadError::DownloadFailed {
reason: "No files were downloaded".to_string(),
})?;
info!(path = %model_dir.display(), "Model downloaded to cache");
Ok(model_dir)
}
fn download_via_hf_cli(
repo_id: &str,
_progress: &ProgressReporter,
) -> Result<PathBuf, DownloadError> {
let hf_check = std::process::Command::new("hf").arg("--version").output();
match hf_check {
Ok(output) if output.status.success() => {
debug!(
"hf CLI found: {}",
String::from_utf8_lossy(&output.stdout).trim()
);
download_with_cli_command("hf", repo_id)
}
_ => {
let hfcli_check = std::process::Command::new("huggingface-cli")
.arg("--version")
.output();
match hfcli_check {
Ok(output) if output.status.success() => {
debug!(
"huggingface-cli found: {}",
String::from_utf8_lossy(&output.stdout).trim()
);
download_with_cli_command("huggingface-cli", repo_id)
}
_ => Err(DownloadError::CliFallbackFailed {
reason: "Neither 'hf' nor 'huggingface-cli' found on PATH".to_string(),
}),
}
}
}
}
fn download_with_cli_command(cmd: &str, repo_id: &str) -> Result<PathBuf, DownloadError> {
info!(cmd = %cmd, repo = %repo_id, "Downloading via CLI");
let output = std::process::Command::new(cmd)
.args([
"download",
repo_id,
"--include",
"*.safetensors",
"--include",
"*.json",
"--include",
"tokenizer.model",
"--exclude",
"*.gguf",
"--exclude",
"*.bin",
"--exclude",
"*.pt",
"--exclude",
"*.h5",
"--exclude",
"*.msgpack",
"--exclude",
"*.ot",
])
.output()
.map_err(|e| DownloadError::CliFallbackFailed {
reason: format!("Failed to execute '{}': {}", cmd, e),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(DownloadError::CliFallbackFailed {
reason: format!("{} download failed: {}", cmd, stderr.trim()),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let download_path = stdout
.lines()
.last()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.ok_or_else(|| DownloadError::CliFallbackFailed {
reason: format!("{} produced no output path", cmd),
})?;
if !download_path.exists() {
return Err(DownloadError::CliFallbackFailed {
reason: format!(
"Downloaded path does not exist: {}",
download_path.display()
),
});
}
info!(path = %download_path.display(), "Model downloaded via CLI");
Ok(download_path)
}
fn resolve_auth_token() -> Option<String> {
if let Ok(token) = std::env::var("HF_TOKEN") {
if !token.is_empty() {
debug!("Using HF_TOKEN from environment");
return Some(token);
}
}
if let Ok(token) = std::env::var("HUGGING_FACE_HUB_TOKEN") {
if !token.is_empty() {
debug!("Using HUGGING_FACE_HUB_TOKEN from environment");
return Some(token);
}
}
if let Some(home) = home_dir() {
let cache_token = home.join(".cache").join("huggingface").join("token");
if let Some(token) = read_token_file(&cache_token) {
debug!(path = %cache_token.display(), "Using token from cache directory");
return Some(token);
}
let legacy_token = home.join(".huggingface").join("token");
if let Some(token) = read_token_file(&legacy_token) {
debug!(path = %legacy_token.display(), "Using token from legacy path");
return Some(token);
}
}
debug!("No HuggingFace auth token found");
None
}
fn read_token_file(path: &std::path::Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn resolve_hf_cache_dir() -> PathBuf {
if let Ok(v) = std::env::var("HF_HUB_CACHE") {
if !v.is_empty() {
return PathBuf::from(v);
}
}
if let Ok(v) = std::env::var("HF_HOME") {
if !v.is_empty() {
return PathBuf::from(v).join("hub");
}
}
if let Ok(v) = std::env::var("XDG_CACHE_HOME") {
if !v.is_empty() {
return PathBuf::from(v).join("huggingface").join("hub");
}
}
home_dir()
.unwrap_or_else(|| PathBuf::from("/"))
.join(".cache")
.join("huggingface")
.join("hub")
}
fn home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.ok()
.map(PathBuf::from)
.or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_auth_token_from_env() {
let original = std::env::var("HF_TOKEN").ok();
std::env::set_var("HF_TOKEN", "test_token_12345");
let token = resolve_auth_token();
assert_eq!(token, Some("test_token_12345".to_string()));
match original {
Some(val) => std::env::set_var("HF_TOKEN", val),
None => std::env::remove_var("HF_TOKEN"),
}
}
#[test]
fn test_resolve_auth_token_empty_env() {
let original = std::env::var("HF_TOKEN").ok();
let original2 = std::env::var("HUGGING_FACE_HUB_TOKEN").ok();
std::env::set_var("HF_TOKEN", "");
std::env::set_var("HUGGING_FACE_HUB_TOKEN", "");
let token = resolve_auth_token();
if let Some(ref t) = token {
assert!(!t.is_empty());
}
match original {
Some(val) => std::env::set_var("HF_TOKEN", val),
None => std::env::remove_var("HF_TOKEN"),
}
match original2 {
Some(val) => std::env::set_var("HUGGING_FACE_HUB_TOKEN", val),
None => std::env::remove_var("HUGGING_FACE_HUB_TOKEN"),
}
}
#[test]
fn test_read_token_file_missing() {
assert!(read_token_file(std::path::Path::new("/nonexistent/path/token")).is_none());
}
#[test]
fn test_read_token_file_valid() {
let tmp = tempfile::tempdir().unwrap();
let token_path = tmp.path().join("token");
std::fs::write(&token_path, "hf_test_token_abc\n").unwrap();
let token = read_token_file(&token_path);
assert_eq!(token, Some("hf_test_token_abc".to_string()));
}
#[test]
fn test_read_token_file_empty() {
let tmp = tempfile::tempdir().unwrap();
let token_path = tmp.path().join("token");
std::fs::write(&token_path, " \n").unwrap();
let token = read_token_file(&token_path);
assert!(token.is_none());
}
#[test]
fn test_home_dir_returns_something() {
let home = home_dir();
assert!(home.is_some());
}
#[test]
fn test_model_class_from_repo_id_qwen35moe() {
let cases = [
"jenerallee78/Qwen3.6-35B-A3B-Abliterix-EGA-abliterated",
"org/Qwen3.5-MoE-35B-Instruct",
"someone/model-35b-a3b-stuff",
];
for repo in &cases {
assert_eq!(
ModelClass::from_repo_id(repo),
ModelClass::Qwen35Moe,
"Expected Qwen35Moe for {repo}"
);
}
}
#[test]
fn test_model_class_from_repo_id_qwen35_dense() {
let cases = ["Qwen/Qwen3.5-27B-Instruct", "org/qwen35-27b-dense"];
for repo in &cases {
assert_eq!(
ModelClass::from_repo_id(repo),
ModelClass::Qwen35Dense,
"Expected Qwen35Dense for {repo}"
);
}
}
#[test]
fn test_model_class_from_repo_id_other() {
let cases = [
"google/gemma-4-26b-it",
"meta-llama/Llama-3.1-8B",
"mistralai/Mistral-7B-v0.1",
];
for repo in &cases {
assert_eq!(
ModelClass::from_repo_id(repo),
ModelClass::Other,
"Expected Other for {repo}"
);
}
}
#[test]
fn test_model_class_min_free_bytes() {
assert_eq!(
ModelClass::Qwen35Moe.min_free_bytes(),
150 * 1024 * 1024 * 1024
);
assert_eq!(
ModelClass::Qwen35Dense.min_free_bytes(),
55 * 1024 * 1024 * 1024
);
assert_eq!(ModelClass::Other.min_free_bytes(), 100 * 1024 * 1024 * 1024);
}
#[test]
fn test_disk_preflight_qwen35moe_insufficient_fails_with_exact_message() {
let tmp = tempfile::tempdir().unwrap();
let available: u64 = 50 * 1024 * 1024 * 1024;
let repo = "jenerallee78/Qwen3.6-35B-A3B-Abliterix-EGA-abliterated";
let err = check_disk_preflight(repo, tmp.path(), Some(available))
.expect_err("Should fail with insufficient disk");
let msg = err.to_string();
assert!(
msg.contains("Qwen3.5-MoE 35B"),
"Error must name the model class: {msg}"
);
assert!(
msg.contains("≥150 GB"),
"Error must state the requirement: {msg}"
);
assert!(msg.contains("50 GB"), "Error must state found bytes: {msg}");
assert!(
msg.contains("Free space or change --cache-dir"),
"Error must be actionable: {msg}"
);
assert!(
msg.contains(tmp.path().to_str().unwrap()),
"Error must include path: {msg}"
);
}
#[test]
fn test_disk_preflight_qwen35moe_sufficient_passes() {
let tmp = tempfile::tempdir().unwrap();
let available: u64 = 200 * 1024 * 1024 * 1024;
let repo = "jenerallee78/Qwen3.6-35B-A3B-Abliterix-EGA-abliterated";
assert!(
check_disk_preflight(repo, tmp.path(), Some(available)).is_ok(),
"200 GB should pass the 150 GB requirement"
);
}
#[test]
fn test_disk_preflight_qwen35_dense_insufficient_fails() {
let tmp = tempfile::tempdir().unwrap();
let available: u64 = 30 * 1024 * 1024 * 1024;
let repo = "Qwen/Qwen3.5-27B-Instruct";
let err = check_disk_preflight(repo, tmp.path(), Some(available)).expect_err("Should fail");
let msg = err.to_string();
assert!(
msg.contains("Qwen3.5 27B dense"),
"Expected dense label: {msg}"
);
assert!(msg.contains("≥55 GB"), "Expected 55 GB requirement: {msg}");
}
#[test]
fn test_disk_preflight_qwen35_dense_sufficient_passes() {
let tmp = tempfile::tempdir().unwrap();
let available: u64 = 100 * 1024 * 1024 * 1024;
let repo = "Qwen/Qwen3.5-27B-Instruct";
assert!(check_disk_preflight(repo, tmp.path(), Some(available)).is_ok());
}
#[test]
fn test_disk_preflight_gemma_regression_passes() {
let tmp = tempfile::tempdir().unwrap();
let available: u64 = 120 * 1024 * 1024 * 1024;
let repo = "google/gemma-4-26b-it";
assert!(
check_disk_preflight(repo, tmp.path(), Some(available)).is_ok(),
"Gemma-4 should pass with 120 GB available (100 GB floor)"
);
}
#[test]
fn test_disk_preflight_gemma_insufficient_fails() {
let tmp = tempfile::tempdir().unwrap();
let available: u64 = 80 * 1024 * 1024 * 1024;
let repo = "google/gemma-4-26b-it";
assert!(
check_disk_preflight(repo, tmp.path(), Some(available)).is_err(),
"Gemma-4 should fail with only 80 GB (100 GB floor)"
);
}
#[test]
fn test_resolve_hf_cache_dir_uses_env_override() {
let original = std::env::var("HF_HUB_CACHE").ok();
std::env::set_var("HF_HUB_CACHE", "/custom/cache");
let dir = resolve_hf_cache_dir();
assert_eq!(dir, std::path::PathBuf::from("/custom/cache"));
match original {
Some(v) => std::env::set_var("HF_HUB_CACHE", v),
None => std::env::remove_var("HF_HUB_CACHE"),
}
}
#[test]
fn test_resolve_hf_cache_dir_returns_path() {
std::env::remove_var("HF_HUB_CACHE");
std::env::remove_var("HF_HOME");
std::env::remove_var("XDG_CACHE_HOME");
let dir = resolve_hf_cache_dir();
assert!(dir.to_str().is_some());
assert!(dir.ends_with("hub") || dir.to_str().unwrap().contains("huggingface"));
}
#[test]
fn test_download_error_messages_are_actionable() {
let err = DownloadError::AuthError {
repo: "meta-llama/Llama-3.1-8B".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("HF_TOKEN"));
assert!(msg.contains("huggingface.co"));
assert!(msg.contains("huggingface-cli login"));
}
#[test]
fn test_download_error_repo_not_found() {
let err = DownloadError::RepoNotFound {
repo: "nonexistent/model".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("org/model-name"));
}
}