1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4use futures_util::{stream, StreamExt};
5use serde::Deserialize;
6use tokio::io::AsyncWriteExt;
7
8use crate::http::{build_client, HF_BASE};
9use crate::log::{log_status, HubError};
10
11pub(crate) const SHARD_DOWNLOAD_CONCURRENCY: usize = 4;
15
16pub struct ModelFiles {
18 pub config_json: PathBuf,
19 pub safetensors_shards: Vec<PathBuf>,
21}
22
23impl ModelFiles {
24 pub fn cleanup_weights(&self) {
30 for f in &self.safetensors_shards {
31 if let Err(e) = std::fs::remove_file(f) {
32 eprintln!("warning: could not remove {}: {e}", f.display());
33 }
34 }
35 }
36}
37
38pub fn canonical_gguf_path(model_dir: &Path, repo_id: &str) -> PathBuf {
43 model_dir
44 .join(repo_id.replace('/', "__"))
45 .join("model-f32.gguf")
46}
47
48pub fn variant_gguf_path(
52 model_dir: &Path,
53 variant_prefix: &str,
54 task: &str,
55 repo_id: &str,
56) -> PathBuf {
57 canonical_gguf_path(&model_dir.join(format!("{variant_prefix}-{task}")), repo_id)
58}
59
60pub fn try_recast_from_cache(
63 canonical: &Path,
64 output: &Path,
65 dtype: zsfm_gguf::GGMLType,
66 redownload: bool,
67) -> anyhow::Result<bool> {
68 if canonical.exists() && !redownload {
69 log_status(&format!(
70 "Using cached F32 GGUF at {} …",
71 canonical.display()
72 ));
73 zsfm_checkpoint::recast(canonical, output, dtype)?;
74 log_status(&format!("Wrote {}", output.display()));
75 return Ok(true);
76 }
77 Ok(false)
78}
79
80pub async fn download_model(
83 repo_id: &str,
84 hf_token: Option<&str>,
85 model_dir: &Path,
86) -> anyhow::Result<ModelFiles> {
87 download_model_prefixed(repo_id, "", hf_token, model_dir).await
88}
89
90pub async fn download_file(
94 repo_id: &str,
95 relpath: &str,
96 hf_token: Option<&str>,
97 dest_dir: &Path,
98) -> anyhow::Result<PathBuf> {
99 let client = build_client(hf_token)?;
100 std::fs::create_dir_all(dest_dir).context("create dest dir")?;
101 fetch_file(&client, repo_id, relpath, dest_dir, None).await
102}
103
104pub async fn download_model_prefixed(
111 repo_id: &str,
112 prefix: &str,
113 hf_token: Option<&str>,
114 model_dir: &Path,
115) -> anyhow::Result<ModelFiles> {
116 let client = build_client(hf_token)?;
117 let model_dir = model_dir.join(repo_id.replace('/', "__"));
125 let model_dir = model_dir.as_path();
126 std::fs::create_dir_all(model_dir).context("create model dir")?;
127
128 let config_rel = joined(prefix, "config.json");
129 println!("Fetching {config_rel} …");
130 let config_json = fetch_file(&client, repo_id, &config_rel, model_dir, None).await?;
131
132 let index_rel = joined(prefix, "model.safetensors.index.json");
134 let single_rel = joined(prefix, "model.safetensors");
135
136 let shards = match fetch_file(&client, repo_id, &index_rel, model_dir, None).await {
137 Ok(index_path) => {
138 println!("Found sharded model — reading index …");
139 resolve_shards(&client, repo_id, &index_path, model_dir, prefix).await?
140 }
141 Err(_) => {
142 println!("Fetching {single_rel} …");
143 let shard = fetch_file(&client, repo_id, &single_rel, model_dir, None).await?;
144 vec![shard]
145 }
146 };
147
148 Ok(ModelFiles {
149 config_json,
150 safetensors_shards: shards,
151 })
152}
153
154fn joined(prefix: &str, filename: &str) -> String {
155 if prefix.is_empty() {
156 filename.to_string()
157 } else {
158 format!("{prefix}/{filename}")
159 }
160}
161
162pub(crate) async fn fetch_file(
170 client: &reqwest::Client,
171 repo_id: &str,
172 relpath: &str,
173 dest_dir: &Path,
174 mp: Option<&indicatif::MultiProgress>,
175) -> anyhow::Result<PathBuf> {
176 let dest = dest_dir.join(relpath.replace('/', "_"));
177 if dest.exists() {
178 let sidecar = dest.with_extension("size");
181 if let Ok(expect) = std::fs::read_to_string(&sidecar) {
182 if let Ok(expect) = expect.trim().parse::<u64>() {
183 if let Ok(actual) = std::fs::metadata(&dest).map(|m| m.len()) {
184 if actual != expect {
185 eprintln!(
186 " (cached {relpath} size mismatch: got {actual}, want {expect} → re-downloading)"
187 );
188 std::fs::remove_file(&dest)?;
189 } else {
190 log_status(&format!(" (cached) {relpath}"));
191 return Ok(dest);
192 }
193 }
194 }
195 } else {
196 log_status(&format!(" (cached) {relpath}"));
197 return Ok(dest);
198 }
199 }
200
201 let dest_tmp = dest.with_extension("tmp");
202 let already = if dest_tmp.exists() {
203 dest_tmp.metadata()?.len()
204 } else {
205 0
206 };
207
208 let url = format!("{HF_BASE}/{repo_id}/resolve/main/{relpath}");
209
210 let mut req = client.get(&url);
211 if already > 0 {
212 req = req.header(reqwest::header::RANGE, format!("bytes={already}-"));
213 log_status(&format!(
214 " Resuming {relpath} from {} MB …",
215 already / 1_000_000
216 ));
217 }
218
219 let response = req.send().await.with_context(|| format!("GET {url}"))?;
220
221 let status = response.status();
222 if !status.is_success() {
224 anyhow::bail!("HTTP {status} fetching {relpath} from {repo_id}");
225 }
226
227 if status == reqwest::StatusCode::PARTIAL_CONTENT && already > 0 {
229 if let Some(range) = response.headers().get(reqwest::header::CONTENT_RANGE) {
230 let range_str = range.to_str().unwrap_or("");
231 if let Some(start) = range_str
233 .strip_prefix("bytes ")
234 .and_then(|s| s.split('-').next())
235 .and_then(|s| s.parse::<u64>().ok())
236 {
237 if start != already {
238 return Err(HubError::ResumeMismatch {
239 relpath: relpath.to_string(),
240 local: already,
241 server: start,
242 }
243 .into());
244 }
245 }
246 }
247 }
248
249 let (file, resume_offset) = if status == reqwest::StatusCode::PARTIAL_CONTENT {
251 let f = tokio::fs::OpenOptions::new()
252 .append(true)
253 .open(&dest_tmp)
254 .await
255 .with_context(|| format!("open tmp {}", dest_tmp.display()))?;
256 (f, already)
257 } else {
258 let f = tokio::fs::File::create(&dest_tmp)
259 .await
260 .with_context(|| format!("create tmp {}", dest_tmp.display()))?;
261 (f, 0)
262 };
263
264 let content_len = response.content_length();
265 let total = content_len.map(|n| n + resume_offset).unwrap_or(0);
266
267 let pb = indicatif::ProgressBar::new(total);
268 let pb = match mp {
269 Some(mp) => mp.add(pb),
270 None => pb,
271 };
272 pb.set_style(
273 indicatif::ProgressStyle::with_template(
274 " {msg} [{bar:40}] {bytes}/{total_bytes} ({bytes_per_sec}, eta {eta})",
275 )
276 .expect("valid progress template")
277 .progress_chars("=>-"),
278 );
279 pb.set_message(relpath.to_string());
280 pb.set_position(resume_offset);
281
282 {
283 let mut file = file;
284 let mut stream = response.bytes_stream();
285 let mut written: u64 = 0;
286 while let Some(chunk) = stream.next().await {
287 let chunk = chunk.with_context(|| format!("stream chunk of {relpath}"))?;
288 pb.inc(chunk.len() as u64);
289 written += chunk.len() as u64;
290 file.write_all(&chunk)
291 .await
292 .with_context(|| format!("write chunk to {}", dest_tmp.display()))?;
293 }
294 if let Some(len) = content_len {
296 if written != len {
297 return Err(HubError::SizeMismatch {
298 relpath: relpath.to_string(),
299 expected: len,
300 actual: written,
301 }
302 .into());
303 }
304 }
305 }
306
307 pb.finish_and_clear();
308 std::fs::rename(&dest_tmp, &dest)
309 .with_context(|| format!("rename tmp → {}", dest.display()))?;
310 if let Ok(meta) = std::fs::metadata(&dest) {
313 let _ = std::fs::write(dest.with_extension("size"), meta.len().to_string());
314 if let Ok(sha) = sha256_file(&dest) {
315 log_status(&format!(" sha256({relpath}) = {sha}"));
316 }
317 }
318
319 Ok(dest)
320}
321
322fn sha256_file(path: &Path) -> anyhow::Result<String> {
324 use sha2::Digest;
325 use std::io::Read;
326 let mut f = std::fs::File::open(path)?;
327 let mut h = sha2::Sha256::new();
328 let mut buf = [0u8; 1 << 20];
329 loop {
330 let n = f.read(&mut buf)?;
331 if n == 0 {
332 break;
333 }
334 h.update(&buf[..n]);
335 }
336 Ok(format!("{:x}", h.finalize()))
337}
338
339async fn resolve_shards(
341 client: &reqwest::Client,
342 repo_id: &str,
343 index_path: &Path,
344 cache_dir: &Path,
345 prefix: &str,
346) -> anyhow::Result<Vec<PathBuf>> {
347 #[derive(Deserialize)]
348 struct Index {
349 weight_map: std::collections::HashMap<String, String>,
350 }
351
352 let raw = std::fs::read_to_string(index_path).context("read index json")?;
353 anyhow::ensure!(
354 raw.len() <= 16 << 20,
355 "shard index too large: {} bytes (max 16 MiB)",
356 raw.len()
357 );
358 let index: Index = serde_json::from_str(&raw).context("parse index json")?;
359
360 let mut shard_names: Vec<String> = index.weight_map.into_values().collect();
361 shard_names.sort();
362 shard_names.dedup();
363 anyhow::ensure!(
364 shard_names.len() <= 512,
365 "too many shards: {} (max 512)",
366 shard_names.len()
367 );
368 {
371 let mut flat: Vec<String> = shard_names
372 .iter()
373 .map(|n| joined(prefix, n).replace('/', "_"))
374 .collect();
375 flat.sort();
376 for w in flat.windows(2) {
377 if w[0] == w[1] {
378 return Err(HubError::ShardCollision(w[0].clone()).into());
379 }
380 }
381 }
382
383 let multi = indicatif::MultiProgress::new();
389 let mut results: Vec<(usize, PathBuf)> = stream::iter(shard_names.iter().enumerate())
390 .map(|(i, name)| {
391 let rel = joined(prefix, name);
392 let multi = &multi;
393 async move {
394 log_status(&format!(" Fetching {rel} …"));
395 fetch_file(client, repo_id, &rel, cache_dir, Some(multi))
396 .await
397 .map(|p| (i, p))
398 }
399 })
400 .buffer_unordered(SHARD_DOWNLOAD_CONCURRENCY)
401 .collect::<Vec<anyhow::Result<(usize, PathBuf)>>>()
402 .await
403 .into_iter()
404 .collect::<anyhow::Result<Vec<_>>>()?;
405 results.sort_by_key(|(i, _)| *i);
406 Ok(results.into_iter().map(|(_, p)| p).collect())
407}