Skip to main content

cherenkov/
download.rs

1//! Pinned Hugging Face downloads. The Hub client handles retries, locking and Xet transfers.
2
3use crate::units::{BYTES_PER_GB, BYTES_PER_MIB};
4use crate::{
5    qwen4_exp::Qwen4ExpConfig,
6    storage::{self, Paths},
7};
8use anyhow::{Context, Result, ensure};
9use hf_hub::{HFClient, HFClientSync, repository::RepoTreeEntry};
10use serde::Deserialize;
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    path::{Path, PathBuf},
14};
15
16const METADATA: &[&str] = &[
17    "config.json",
18    "tokenizer.json",
19    "tokenizer_config.json",
20    "generation_config.json",
21    "chat_template.jinja",
22    "model.safetensors.index.json",
23    "LICENSE",
24    "LICENSE.txt",
25    "LICENSE.md",
26    "NOTICE",
27    "NOTICE.txt",
28    "NOTICE.md",
29];
30
31pub struct Download<'a> {
32    pub repo: &'a str,
33    pub revision: &'a str,
34    pub token: Option<&'a str>,
35    pub metadata_only: bool,
36}
37
38#[derive(Deserialize)]
39struct Index {
40    weight_map: BTreeMap<String, String>,
41}
42
43pub fn run(paths: &Paths, request: Download<'_>) -> Result<PathBuf> {
44    run_at(paths, request, None)
45}
46
47pub(crate) fn run_at(
48    paths: &Paths,
49    request: Download<'_>,
50    endpoint: Option<&str>,
51) -> Result<PathBuf> {
52    // Validate path components before contacting the Hub. Branch names themselves
53    // are sent to the API, then replaced with the returned immutable commit.
54    paths.model(request.repo, storage::DEFAULT_REVISION)?;
55    storage::create_private_dir(&paths.data)?;
56    storage::create_private_dir(&paths.scratch)?;
57
58    let lock = std::fs::File::options()
59        .create(true)
60        .truncate(false)
61        .write(true)
62        .open(paths.data.join("download.lock"))?;
63
64    lock.try_lock()
65        .context("another download is using this root")?;
66
67    let mut builder = HFClient::builder().cache_dir(paths.downloads());
68
69    if let Some(endpoint) = endpoint {
70        builder = builder.endpoint(endpoint);
71    }
72
73    if let Some(token) = request.token {
74        ensure!(!token.trim().is_empty(), "HF token must not be empty");
75
76        builder = builder.token(token);
77    }
78
79    let client = HFClientSync::from_inner(builder.build()?)?;
80    let (owner, name) = hf_hub::split_id(request.repo);
81    let repo = client.model(owner, name);
82    let info = repo.info().revision(request.revision).send()?;
83    let commit = info.sha.context("Hub response has no commit hash")?;
84    let model = paths.model(request.repo, &commit)?;
85    let snapshot = paths.snapshot(request.repo, &commit)?;
86    let entries = repo.list_tree().revision(&commit).recursive(false).send()?;
87    let available: BTreeMap<String, u64> = entries
88        .into_iter()
89        .filter_map(|e| match e {
90            RepoTreeEntry::File { path, size, .. } => Some((path, size)),
91            _ => None,
92        })
93        .collect();
94
95    ensure!(
96        available.contains_key("config.json") && available.contains_key("tokenizer.json"),
97        "checkpoint needs config.json and tokenizer.json"
98    );
99
100    let mut files: BTreeSet<String> = METADATA
101        .iter()
102        .filter(|f| available.contains_key(**f))
103        .map(|s| (*s).into())
104        .collect();
105
106    eprintln!(
107        "downloading {} at {commit} into {}",
108        request.repo,
109        model.display()
110    );
111
112    for name in &files {
113        ensure!(
114            available[name] <= 64 * BYTES_PER_MIB as u64,
115            "metadata file {name} exceeds 64 MiB"
116        );
117    }
118
119    storage::require_space(&paths.data, missing_bytes(&files, &available, &snapshot)?)?;
120    // Validate the architecture before fetching tokenizer or weight payloads.
121    repo.download_file()
122        .filename("config.json")
123        .revision(&commit)
124        .send()?;
125    Qwen4ExpConfig::load(&snapshot)?;
126
127    if !request.metadata_only {
128        if available.contains_key("model.safetensors.index.json") {
129            let index = repo
130                .download_file()
131                .filename("model.safetensors.index.json")
132                .revision(&commit)
133                .send()?;
134
135            files.extend(shards(&std::fs::read(index)?)?);
136        } else {
137            files.insert("model.safetensors".into());
138        }
139    }
140
141    let needed = missing_bytes(&files, &available, &snapshot)?;
142
143    storage::require_space(&paths.data, needed)?;
144    storage::require_space(&paths.scratch, 0)?;
145    eprintln!(
146        "{} selected files, {:.2} GB not yet cached",
147        files.len(),
148        needed as f64 / BYTES_PER_GB as f64
149    );
150
151    let snapshot = repo
152        .snapshot_download()
153        .revision(&commit)
154        .allow_patterns(files.iter().cloned().collect())
155        .max_workers(4)
156        .progress(progress::Reporter::default())
157        .send()?;
158
159    // Publish only completed, size-checked downloads. Links share durable Hub
160    // blobs; packed and low-bit stores live separately in the model's packed/.
161    ensure!(
162        missing_bytes(&files, &available, &snapshot)? == 0,
163        "download did not produce all selected files at their expected sizes"
164    );
165    publish(&model, &snapshot, &files)?;
166
167    Ok(model)
168}
169
170fn shards(bytes: &[u8]) -> Result<BTreeSet<String>> {
171    let index: Index = serde_json::from_slice(bytes).context("safetensors index")?;
172    let shards: BTreeSet<_> = index.weight_map.into_values().collect();
173
174    ensure!(!shards.is_empty(), "empty safetensors index");
175
176    for shard in &shards {
177        ensure!(
178            storage::safe_component(shard) && shard.ends_with(".safetensors"),
179            "unsupported shard filename {shard:?}"
180        );
181    }
182
183    Ok(shards)
184}
185
186fn missing_bytes(
187    files: &BTreeSet<String>,
188    available: &BTreeMap<String, u64>,
189    snapshot: &Path,
190) -> Result<u64> {
191    let mut total = 0u64;
192
193    for file in files {
194        ensure!(
195            storage::safe_component(file),
196            "unsupported filename {file:?}"
197        );
198
199        let size = *available
200            .get(file)
201            .with_context(|| format!("checkpoint is missing {file}"))?;
202
203        if !std::fs::metadata(snapshot.join(file)).is_ok_and(|m| m.is_file() && m.len() == size) {
204            total = total.checked_add(size).context("download size overflow")?;
205        }
206    }
207
208    Ok(total)
209}
210
211fn publish(model: &Path, snapshot: &Path, files: &BTreeSet<String>) -> Result<()> {
212    storage::create_private_dir(model)?;
213
214    for file in files {
215        let dest = model.join(file);
216        let source = snapshot.join(file).canonicalize()?;
217
218        match std::fs::symlink_metadata(&dest) {
219            Ok(_) => ensure!(
220                dest.canonicalize()? == source,
221                "refusing to replace {}",
222                dest.display()
223            ),
224            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
225                std::os::unix::fs::symlink(source, dest)?
226            }
227            Err(e) => return Err(e.into()),
228        }
229    }
230
231    Ok(())
232}
233
234mod progress;
235#[cfg(test)]
236#[path = "../tests/unit/download.rs"]
237mod tests;