1use 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 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 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 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;