Skip to main content

zsfm_hub/
download.rs

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
11/// How many shard files to fetch concurrently. HF's CDN comfortably serves this many
12/// parallel range/GET requests per client; higher offers diminishing returns and risks
13/// the server throttling or the local connection pool thrashing.
14pub(crate) const SHARD_DOWNLOAD_CONCURRENCY: usize = 4;
15
16/// Paths to all local files needed for conversion.
17pub struct ModelFiles {
18    pub config_json: PathBuf,
19    /// Ordered list of safetensors shard paths, already downloaded locally.
20    pub safetensors_shards: Vec<PathBuf>,
21}
22
23impl ModelFiles {
24    /// Delete the (potentially large) weight shards once they've been converted into a
25    /// GGUF, leaving `config_json` in place — several `infer` commands read it directly
26    /// (architecture parameters not embedded in the GGUF), and it's tiny, so there's no
27    /// disk-space reason to remove it. Best-effort: a failed delete is not fatal, since
28    /// the GGUF conversion has already succeeded by the time this is called.
29    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
38/// Where the canonical, always-F32 GGUF for `repo_id` lives once converted once. Every
39/// per-model `convert` command checks this path first: if present, it recasts straight
40/// from this cached GGUF to whatever dtype was requested instead of re-downloading and
41/// re-converting from HuggingFace.
42pub 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
48/// Variant-aware cache path for multi-task repos (Mitra, TabFM):
49/// `<model_dir>/<variant>-<task>/<owner>__<name>/model-f32.gguf`.
50/// Centralizes the `format!("mitra-{task}")` logic duplicated in CLI + Python.
51pub 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
60/// Shared cache-hit path for CLI + Python: recast from canonical F32 GGUF when
61/// present. Returns `true` when the caller can return early.
62pub 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
80/// Download (or locate from cache) `config.json` + `model.safetensors[.index.json]`
81/// for `repo_id` into `model_dir`.
82pub 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
90/// Download (or locate from cache) a single arbitrary file from `repo_id`, resuming a partial
91/// download if one exists. For models that don't fit the `config.json` + `model.safetensors`
92/// shape — e.g. Lag-Llama's raw PyTorch Lightning `.ckpt` checkpoint.
93pub 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
104/// Same as [`download_model`], but every remote path is joined under `prefix` first.
105///
106/// Pass `""` for a flat repo layout (`config.json`, `model.safetensors`).
107/// Pass e.g. `"classification"` for a repo that keeps multiple task variants
108/// in subfolders (`classification/config.json`, `classification/model.safetensors`),
109/// as TabFM does.
110pub 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    // Namespace by repo (matching the `owner__name` convention the generic `zsfm convert
118    // --repo` path already uses) so two different repos sharing the generic `config.json` +
119    // `model.safetensors` naming — which is most of them — never collide when a caller passes
120    // the same (often default) `model_dir` for both, whether run sequentially or concurrently.
121    // Without this, the second download's `config.json`/`model.safetensors` would either
122    // silently overwrite the first's files, or worse, get "(cached)" hit on the *wrong* model's
123    // bytes.
124    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    // Detect sharded model by fetching the index file.
133    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
162/// Download `relpath` (may include subfolders, e.g. `"classification/config.json"`)
163/// from `repo_id` into `dest_dir`, resuming if a partial `.tmp` file already exists.
164/// Returns the local path of the completed file.
165///
166/// `mp`: when several `fetch_file` calls run concurrently (see `resolve_shards`), pass a
167/// shared [`indicatif::MultiProgress`] so their bars stack cleanly instead of each fighting
168/// to redraw the same terminal line; `None` draws a lone standalone bar.
169pub(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        // Validate legacy cache hits against the `.size` sidecar written on
179        // first download; a mismatch means corruption → re-download.
180        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    // 206 = partial content (resume accepted), 200 = full content
223    if !status.is_success() {
224        anyhow::bail!("HTTP {status} fetching {relpath} from {repo_id}");
225    }
226
227    // Verify the server honored the Range offset when it claims 206.
228    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            // Expected form: `bytes <start>-<end>/<total>`.
232            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    // If server ignored the Range header and sent 200, truncate the tmp file.
250    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        // When the server reports a length, the byte count must match.
295        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    // Record final size for future cache validation + log SHA256 so users can
311    // compare against the Hub file page when debugging corruption.
312    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
322/// Streaming SHA256 without loading multi-GB files into memory.
323fn 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
339/// Parse the shard index JSON and download every unique shard (joined under `prefix`).
340async 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    // Flattened filenames must stay unique — `a/b.safetensors` and `a_b.safetensors`
369    // would otherwise overwrite each other in the cache dir.
370    {
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    // Fetch up to SHARD_DOWNLOAD_CONCURRENCY shards at once — each is an independent file,
384    // so there's no reason to serialize what's fundamentally a bandwidth-bound operation.
385    // `buffer_unordered` completes them in whatever order the network delivers them; each
386    // result is tagged with its original index so the returned `Vec<PathBuf>` still matches
387    // `shard_names`' sorted order (some downstream converters iterate shards positionally).
388    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}