1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
//! `frink download <repo> [file] [--local-dir DIR]`
//!
//! Deliberately the same shape as `hf download`, so a command copied
//! off a model card runs unchanged. The point is that it needs no
//! Python: `frink pull` used to shell out to the `hf` CLI, so a Rust
//! engine could not fetch its own weights without `pip install
//! huggingface_hub`.
//!
//! The transport is [`frink_models::hub`], which `frink-server`
//! already used for `POST /admin/download`: IPv4 resolved first because
//! Hugging Face publishes AAAA records that black-hole on some
//! networks, `HF_TOKEN` for gated repos, `HF_ENDPOINT` for mirrors, and
//! a byte range so an interrupted download resumes.
use std::io::Write;
use std::path::PathBuf;
#[derive(clap::Args, Debug)]
pub struct DownloadArgs {
/// Hugging Face repo id, for example
/// `bartowski/Llama-3.2-3B-Instruct-GGUF`.
///
/// Also accepts llama.cpp's `-hf` shape, `repo:QUANT`, so
/// `TheBloke/Mixtral-8x7B-Instruct-v0.1-GGUF:Q4_K_M` works. Before
/// that, the whole string was sent to the Hub as a repo id and came
/// back a bare `401`, which reads like an auth problem and is not
/// one.
pub repo: String,
/// File to fetch, or a glob. Defaults to the repo's single GGUF,
/// and says so rather than choosing when several match.
#[arg(default_value = "*.gguf")]
pub file: String,
/// Where to put it. `hf download` spells this the same way.
#[arg(long = "local-dir", default_value = "models")]
pub local_dir: PathBuf,
}
pub fn run(args: DownloadArgs) -> anyhow::Result<()> {
let mut last = std::time::Instant::now();
// Throttled: a fast link should spend its time on bytes, not on
// formatting a line nobody can read at 300 Hz.
let mut draw = move |done: u64, total: Option<u64>| {
if last.elapsed() < std::time::Duration::from_millis(200) {
return;
}
last = std::time::Instant::now();
let mib = done as f64 / 1024.0 / 1024.0;
match total {
Some(t) if t > 0 => {
print!(
"\r {mib:>9.1} MiB {:5.1}%",
(done as f64 / t as f64) * 100.0
)
}
_ => print!("\r {mib:>9.1} MiB"),
}
let _ = std::io::stdout().flush();
};
// `repo:QUANT` is resolved here rather than passed through, so the
// quant tag matches case-insensitively the way llama.cpp's does.
// An explicit `file` argument still wins: it is what the user typed.
let hf = frink_models::hub::HfRef::parse(&args.repo);
let (repo, file) = match (&hf.quant, args.file.as_str()) {
(Some(_), "*.gguf") => {
let resolved = hf.resolve().map_err(|e| anyhow::anyhow!("{e}"))?;
println!("resolved {} to {resolved}", args.repo);
(hf.repo.clone(), resolved)
}
_ => (hf.repo.clone(), args.file.clone()),
};
let path =
frink_models::hub::fetch_to_dir_with_progress(&repo, &file, &args.local_dir, &mut draw)
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!();
let gb = std::fs::metadata(&path)
.map(|m| m.len() as f64 / 1024.0 / 1024.0 / 1024.0)
.unwrap_or(0.0);
println!("saved {} ({gb:.2} GiB)", path.display());
Ok(())
}