Skip to main content

hf_fetch_model/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! # hf-fetch-model
4//!
5//! Fast `HuggingFace` model downloads for Rust.
6//!
7//! An embeddable library for downloading `HuggingFace` model repositories
8//! with maximum throughput. Wraps [`hf_hub`] and adds repo-level orchestration.
9//!
10//! ## Quick Start
11//!
12//! ```rust,no_run
13//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
14//! let outcome = hf_fetch_model::download("julien-c/dummy-unknown".to_owned()).await?;
15//! println!("Model at: {}", outcome.inner().display());
16//! # Ok(())
17//! # }
18//! ```
19//!
20//! ## Configured Download
21//!
22//! ```rust,no_run
23//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
24//! use hf_fetch_model::FetchConfig;
25//!
26//! let config = FetchConfig::builder()
27//!     .filter("*.safetensors")
28//!     .filter("*.json")
29//!     .on_progress(|e| {
30//!         println!("{}: {:.1}%", e.filename, e.percent);
31//!     })
32//!     .build()?;
33//!
34//! let outcome = hf_fetch_model::download_with_config(
35//!     "google/gemma-2-2b".to_owned(),
36//!     &config,
37//! ).await?;
38//! // outcome.is_cached() tells you if it came from local cache
39//! let path = outcome.into_inner();
40//! # Ok(())
41//! # }
42//! ```
43//!
44//! ## Inspect Before Downloading
45//!
46//! Read tensor metadata from `.safetensors` headers — and, since v0.11.0,
47//! `NumPy` `.npz` archive directories — via HTTP Range requests, no weight
48//! data downloaded. Sharded repos (those with
49//! `model.safetensors.index.json`) work transparently —
50//! [`inspect::inspect_repo_safetensors`] reads every shard's header in parallel
51//! and returns a flat per-file result list. See
52//! [`examples/candle_inspect.rs`](https://github.com/mi-for-the-rust-of-us/hf-fetch-model/blob/main/examples/candle_inspect.rs)
53//! for a runnable example, or the
54//! [Inspect tutorial](https://github.com/mi-for-the-rust-of-us/hf-fetch-model/blob/main/docs/tutorials/inspect-before-downloading.md)
55//! for a narrative walkthrough.
56//!
57//! ```rust,no_run
58//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
59//! let results = hf_fetch_model::inspect::inspect_repo_safetensors(
60//!     "EleutherAI/pythia-1.4b", None, None,
61//! ).await?;
62//!
63//! for (filename, header, _source) in &results {
64//!     println!("{filename}: {} tensors", header.tensors.len());
65//! }
66//! # Ok(())
67//! # }
68//! ```
69//!
70//! The CLI also exposes `hf-fm inspect <repo> [FILE] --check-gpu [N]` (v0.10.1)
71//! to print a one-line GPU-fit verdict against device `N` (default 0) using
72//! the `hypomnesis` crate (NVML on Linux/Windows, DXGI on Windows). Adding
73//! `--context N` (v0.10.4) folds in the KV cache at a context length and
74//! reports a real fit against `weights + KV` instead of weights alone — the
75//! difference between "fits" and "out-of-memory at token 8000" on a consumer
76//! card. The architecture parameters come from the model's `config.json`,
77//! parsed by the library API that v0.10.4 exposes for downstream reuse:
78//! [`inspect::ModelConfig`] plus [`inspect::fetch_model_config`] /
79//! [`inspect::fetch_model_config_cached`] (cache-first or cache-only) and the
80//! [`inspect::torch_dtype_bytes`] helper. The KV math itself — `GQA`,
81//! sliding-window, `MLA`-skip, and hybrid Mamba/attention layer counting —
82//! and the verdict rendering stay binary-only; depend on `hypomnesis`
83//! directly for the raw device-info numbers.
84//!
85//! ## Cached-file Inspection
86//!
87//! Beyond the remote-or-cached `.safetensors` / `.npz` paths above,
88//! [`inspect::inspect_gguf_cached`] (v0.10.2),
89//! [`inspect::inspect_npz_cached`], and [`inspect::inspect_pth_cached`]
90//! (both v0.10.3) extend inspect to `GGUF` / `NumPy` `.npz` / `PyTorch`
91//! `.pth` files in the local cache via the `anamnesis` parser crate. All
92//! four formats return the same format-agnostic
93//! [`inspect::SafetensorsHeaderInfo`] shape, so downstream pipeline steps
94//! (filter, tree, dtypes aggregation) work uniformly across formats.
95//!
96//! For cached `.safetensors` files, v0.10.3 also surfaces quantization
97//! detection. When [`inspect::inspect_safetensors_local`] sees a quantized
98//! header (`FP8` variants, `GPTQ`, `AWQ`, `BnB-NF4`, `BnB-INT8`), it
99//! populates the new [`inspect::QuantInfo`] field with the scheme name and
100//! both stored + dequantised byte sizes. Unquantized safetensors and
101//! non-safetensors formats leave `quant_info` as `None`.
102//!
103//! ```rust,no_run
104//! # fn example() -> Result<(), hf_fetch_model::FetchError> {
105//! use hf_fetch_model::inspect;
106//! use std::path::Path;
107//!
108//! let header = inspect::inspect_safetensors_local(
109//!     Path::new("/path/to/cached/file.safetensors"),
110//! )?;
111//! if let Some(q) = &header.quant_info {
112//!     println!(
113//!         "Quantized as {}: {} stored -> {} dequantised",
114//!         q.scheme, q.stored_bytes, q.dequantized_bytes,
115//!     );
116//! }
117//! # Ok(())
118//! # }
119//! ```
120//!
121//! Remote inspect via HTTP Range (without going through the cache) shipped
122//! incrementally: `NPZ` in v0.11.0, safetensors in v0.11.1, `GGUF` in
123//! v0.11.2, `PTH` in v0.11.4 ([`inspect::inspect_npz`] /
124//! [`inspect::inspect_safetensors`] / [`inspect::inspect_gguf`] /
125//! [`inspect::inspect_pth`] each drive an anamnesis reader-based parser
126//! over an [`HttpRangeReader`] — see the [`http_range`] module for the
127//! substrate: tail prefetch, read-ahead, hard transfer budgets, token-free
128//! CDN requests). All four formats are now remote-capable, closing the
129//! matrix opened in v0.11.0.
130//!
131//! For discovery — "what tensor files does this cached repo hold?" —
132//! [`inspect::list_cached_tensor_files`] (v0.10.5) enumerates
133//! `(filename, size)` pairs across all four formats without parsing any
134//! headers, with [`inspect::is_supported_tensor_file`] /
135//! [`inspect::SUPPORTED_TENSOR_EXTENSIONS`] as the shared extension
136//! predicate. The `.safetensors`-only [`inspect::list_cached_safetensors`]
137//! (v0.9.7) remains for callers that want exactly that subset. These back
138//! the CLI's `inspect --list`, numeric-index, and `--pick` flows.
139//!
140//! ## `HuggingFace` Cache
141//!
142//! Downloaded files are stored in the standard `HuggingFace` cache directory
143//! (`~/.cache/huggingface/hub/`), ensuring compatibility with Python tooling.
144//!
145//! ## Cache Management
146//!
147//! v0.10.0 adds library APIs for inspecting, verifying, and pruning the local
148//! cache. [`cache::cache_summary`] enumerates every cached repo with size and
149//! file counts; [`cache::repo_status`] gives a per-file `Complete` / `Partial` /
150//! `Missing` / `Excluded` breakdown for one repo (since v0.10.5, partials are
151//! attributed per-file via each file's own `blobs/<sha256>.chunked.part` temp
152//! blob rather than a repo-level heuristic); [`cache::verify_cache`] re-checks
153//! `SHA256` digests of cached files against `HuggingFace` LFS metadata; and
154//! [`cache::find_partial_files`] locates `.chunked.part` orphans from
155//! interrupted downloads.
156//!
157//! For long verifications (multi-GiB safetensors files), drive
158//! [`cache::verify_cache_with_progress`] with an [`Fn`] callback that receives
159//! [`cache::VerifyEvent`]s so a CLI or GUI can render a spinner or progress
160//! bar without polling.
161//!
162//! ```rust,no_run
163//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
164//! use hf_fetch_model::cache::{self, VerifyStatus};
165//!
166//! let results = cache::verify_cache("google/gemma-2-2b-it", None, None).await?;
167//! let ok = results
168//!     .iter()
169//!     .filter(|r| matches!(r.status, VerifyStatus::Ok))
170//!     .count();
171//! let mismatch = results
172//!     .iter()
173//!     .filter(|r| matches!(r.status, VerifyStatus::Mismatch { .. }))
174//!     .count();
175//! println!("{}/{} files verified, {} mismatches", ok, results.len(), mismatch);
176//! # Ok(())
177//! # }
178//! ```
179//!
180//! ## Download Durability
181//!
182//! Multi-connection downloads survive interruption. When a download is
183//! aborted by [`FetchConfigBuilder::timeout_per_file`] (default 300 s),
184//! Ctrl-C, panic, or a transient chunk error, the partial `.chunked.part`
185//! file plus a small per-chunk progress sidecar are kept on disk. The next
186//! call to [`download_with_config`] for the same file picks up where it
187//! stopped — each parallel chunk sends a fresh `Range` request that skips
188//! the bytes it already has — provided the upstream etag still matches.
189//! On etag change, schema-version mismatch, or a different
190//! [`FetchConfigBuilder::connections_per_file`] count, the partial is
191//! discarded and a fresh download starts.
192//!
193//! For slow connections on multi-GiB files, raise the per-file budget to
194//! match real throughput:
195//!
196//! ```rust,no_run
197//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
198//! use std::time::Duration;
199//! use hf_fetch_model::FetchConfig;
200//!
201//! let config = FetchConfig::builder()
202//!     .timeout_per_file(Duration::from_secs(1800))
203//!     .build()?;
204//! # let _ = hf_fetch_model::download_with_config(
205//! #     "google/gemma-4-E2B-it".to_owned(),
206//! #     &config,
207//! # ).await?;
208//! # Ok(())
209//! # }
210//! ```
211//!
212//! ## Authentication
213//!
214//! Set the `HF_TOKEN` environment variable to access private or gated models,
215//! or use [`FetchConfig::builder().token()`](FetchConfigBuilder::token).
216//!
217//! Gated repos (Meta Llama, Google Gemma, …) additionally require accepting
218//! the license on the model's `HuggingFace` page — once per gated family
219//! (a Llama 3.2 grant does not cover Llama 3.1). [`download()`] /
220//! [`download_with_config`] pre-flight the gate and return
221//! [`FetchError::Auth`] with the license URL before any transfer starts.
222//! The library-level [`inspect`] functions surface the underlying HTTP
223//! `401` / `403` as [`FetchError::Http`] instead — note that the Hub serves
224//! a gated repo's *metadata* publicly, so file listings succeed while
225//! content requests fail. The `hf-fm` CLI upgrades such `inspect` / `diff`
226//! failures into the same gated-model diagnosis the download pre-flight
227//! emits (v0.10.5).
228
229mod atomic_write;
230pub mod cache;
231pub mod cache_layout;
232pub mod checksum;
233mod chunked;
234mod chunked_state;
235pub mod config;
236pub mod discover;
237pub mod download;
238pub mod error;
239pub mod header_cache;
240pub mod http_range;
241pub mod inspect;
242pub mod peek;
243pub mod plan;
244pub mod progress;
245pub mod repo;
246mod retry;
247
248pub use chunked::build_client;
249pub use config::{
250    FetchConfig, FetchConfigBuilder, Filter, compile_glob_patterns, file_matches, has_glob_chars,
251};
252pub use discover::{DiscoveredFamily, GateStatus, ModelCardMetadata, SearchResult};
253pub use download::DownloadOutcome;
254pub use error::{FetchError, FileFailure};
255pub use http_range::{HttpRangeReader, RangeFetcher, RangeReader, RangeStats};
256pub use inspect::{AdapterConfig, ModelConfig};
257pub use plan::{DownloadPlan, FilePlan, download_plan};
258pub use progress::{ProgressEvent, ProgressReceiver};
259
260use std::collections::HashMap;
261use std::path::PathBuf;
262
263use hf_hub::{HFClient, split_id};
264
265use crate::repo::ModelRepo;
266
267/// Builds an `hf-hub` client and resolves it to a [`ModelRepo`] handle.
268///
269/// Shared by the three `*_with_config` entry points, which differ only in
270/// what they do with the resulting handle. Honours the config's token and
271/// `output_dir` (used as the cache root) and carries the requested revision
272/// on the handle.
273///
274/// **The cache directory is always set explicitly**, never left to `hf-hub`'s
275/// own resolution. `hf-hub` 1.0 derives its default from the `HOME`
276/// environment variable alone, falling back to `/tmp` — and Windows does not
277/// set `HOME` (it uses `USERPROFILE`), so on Windows the default resolves to
278/// `C:\tmp\.cache\huggingface\hub` while every other command in this crate
279/// reads [`cache::hf_cache_dir`]. Pinning it here keeps downloads and cache
280/// introspection pointed at the same directory on every platform, and keeps
281/// [`cache::hf_cache_dir`] the single source of truth it already is for
282/// `du` / `status` / `cache *` / `inspect --cached`.
283///
284/// # Errors
285///
286/// Returns [`FetchError::Api`] if the `hf-hub` client cannot be constructed.
287/// Returns [`FetchError::Io`] if the home directory cannot be determined and
288/// no explicit `output_dir` was configured.
289fn build_model_repo(repo_id: &str, config: &FetchConfig) -> Result<ModelRepo, FetchError> {
290    let mut builder = HFClient::builder();
291
292    if let Some(ref token) = config.token {
293        // BORROW: explicit .clone() to pass owned String
294        builder = builder.token(token.clone());
295    }
296
297    let cache_dir = match config.output_dir {
298        // BORROW: explicit .clone() for owned PathBuf
299        Some(ref dir) => dir.clone(),
300        None => cache::hf_cache_dir()?,
301    };
302    builder = builder.cache_dir(cache_dir);
303
304    let client = builder.build().map_err(FetchError::Api)?;
305
306    // `hf-hub` 1.0 addresses repositories by (owner, name) rather than by a
307    // single "org/name" string; `split_id` yields an empty owner for a
308    // canonical-namespace repo such as `gpt2`, which is what the Hub expects.
309    let (owner, name) = split_id(repo_id);
310    Ok(ModelRepo::new(
311        client.model(owner, name),
312        config.revision.clone(),
313    ))
314}
315
316/// Pre-flight check for gated model access.
317///
318/// Two cases:
319/// - **No token**: checks the model metadata (unauthenticated) for gating
320///   status and rejects with a clear message if gated.
321/// - **Token present**: if the model is gated, makes one authenticated
322///   metadata request to verify the token actually grants access. Catches
323///   invalid tokens and unaccepted licenses before the download starts.
324///
325/// If the metadata request itself fails (network error, private repo),
326/// the check is silently skipped so that normal download error handling
327/// can take over.
328async fn preflight_gated_check(repo_id: &str, config: &FetchConfig) -> Result<(), FetchError> {
329    // Best-effort: if the metadata call fails, let the download proceed.
330    let Ok(metadata) = discover::fetch_model_card(repo_id).await else {
331        return Ok(());
332    };
333
334    if !metadata.gated.is_gated() {
335        return Ok(());
336    }
337
338    // Model is gated — check auth.
339    if config.token.is_none() {
340        return Err(FetchError::Auth {
341            reason: format!(
342                "{repo_id} is a gated model — accept the license at \
343                 https://huggingface.co/{repo_id} and set HF_TOKEN or pass --token"
344            ),
345        });
346    }
347
348    // Token is present — verify it grants access with a lightweight probe.
349    let probe_client = chunked::build_client(config.token.as_deref())?;
350    let probe = repo::list_repo_files_with_metadata(
351        repo_id,
352        config.token.as_deref(),
353        config.revision.as_deref(),
354        &probe_client,
355    )
356    .await;
357
358    if let Err(ref e) = probe {
359        // BORROW: explicit .to_string() for error Display formatting
360        let msg = e.to_string();
361        if msg.contains("401") || msg.contains("403") {
362            return Err(FetchError::Auth {
363                reason: format!(
364                    "{repo_id} is a gated model and your token was rejected — \
365                     accept the license at https://huggingface.co/{repo_id} \
366                     and check that your token is valid"
367                ),
368            });
369        }
370    }
371
372    Ok(())
373}
374
375/// Downloads all files from a `HuggingFace` model repository.
376///
377/// Uses high-throughput mode for maximum download speed, including
378/// auto-tuned concurrency, chunked multi-connection downloads for large
379/// files, and plan-optimized settings based on file size distribution.
380/// Files are stored in the standard `HuggingFace` cache layout
381/// (`~/.cache/huggingface/hub/`).
382///
383/// Authentication is handled via the `HF_TOKEN` environment variable when set.
384///
385/// For filtering, progress, and other options, use [`download_with_config()`].
386///
387/// # Arguments
388///
389/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
390///
391/// # Returns
392///
393/// The path to the snapshot directory containing all downloaded files.
394///
395/// # Errors
396///
397/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
398/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
399/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
400/// * [`FetchError::InvalidPattern`] — if the default config fails to build (should not happen).
401pub async fn download(repo_id: String) -> Result<DownloadOutcome<PathBuf>, FetchError> {
402    let config = FetchConfig::builder().build()?;
403    download_with_config(repo_id, &config).await
404}
405
406/// Downloads files from a `HuggingFace` model repository using the given configuration.
407///
408/// Supports filtering, progress reporting, custom revision, authentication,
409/// and concurrency settings via [`FetchConfig`].
410///
411/// # Arguments
412///
413/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
414/// * `config` — Download configuration (see [`FetchConfig::builder()`]).
415///
416/// # Returns
417///
418/// The path to the snapshot directory containing all downloaded files.
419///
420/// # Errors
421///
422/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
423/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
424/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
425pub async fn download_with_config(
426    repo_id: String,
427    config: &FetchConfig,
428) -> Result<DownloadOutcome<PathBuf>, FetchError> {
429    // BORROW: explicit .as_str() instead of Deref coercion
430    preflight_gated_check(repo_id.as_str(), config).await?;
431
432    // BORROW: explicit .as_str() instead of Deref coercion
433    let repo = build_model_repo(repo_id.as_str(), config)?;
434    download::download_all_files(repo, repo_id, Some(config)).await
435}
436
437/// Blocking version of [`download()`] for non-async callers.
438///
439/// Creates a Tokio runtime internally. Do not call from within
440/// an existing async context (use [`download()`] instead).
441///
442/// # Errors
443///
444/// Same as [`download()`].
445pub fn download_blocking(repo_id: String) -> Result<DownloadOutcome<PathBuf>, FetchError> {
446    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
447        path: PathBuf::from("<runtime>"),
448        source: e,
449    })?;
450    rt.block_on(download(repo_id))
451}
452
453/// Blocking version of [`download_with_config()`] for non-async callers.
454///
455/// Creates a Tokio runtime internally. Do not call from within
456/// an existing async context (use [`download_with_config()`] instead).
457///
458/// # Errors
459///
460/// Same as [`download_with_config()`].
461pub fn download_with_config_blocking(
462    repo_id: String,
463    config: &FetchConfig,
464) -> Result<DownloadOutcome<PathBuf>, FetchError> {
465    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
466        path: PathBuf::from("<runtime>"),
467        source: e,
468    })?;
469    rt.block_on(download_with_config(repo_id, config))
470}
471
472/// Downloads all files from a `HuggingFace` model repository and returns
473/// a filename → path map.
474///
475/// Each key is the relative filename within the repository (e.g.,
476/// `"config.json"`, `"model.safetensors"`), and each value is the
477/// absolute local path to the downloaded file.
478///
479/// Uses the same high-throughput defaults as [`download()`]: auto-tuned
480/// concurrency and chunked multi-connection downloads for large files.
481///
482/// For filtering, progress, and other options, use
483/// [`download_files_with_config()`].
484///
485/// # Arguments
486///
487/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
488///
489/// # Errors
490///
491/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
492/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
493/// * [`FetchError::InvalidPattern`] — if the default config fails to build (should not happen).
494pub async fn download_files(
495    repo_id: String,
496) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
497    let config = FetchConfig::builder().build()?;
498    download_files_with_config(repo_id, &config).await
499}
500
501/// Downloads files from a `HuggingFace` model repository using the given
502/// configuration and returns a filename → path map.
503///
504/// Each key is the relative filename within the repository (e.g.,
505/// `"config.json"`, `"model.safetensors"`), and each value is the
506/// absolute local path to the downloaded file.
507///
508/// # Arguments
509///
510/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
511/// * `config` — Download configuration (see [`FetchConfig::builder()`]).
512///
513/// # Errors
514///
515/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
516/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
517/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
518pub async fn download_files_with_config(
519    repo_id: String,
520    config: &FetchConfig,
521) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
522    // BORROW: explicit .as_str() instead of Deref coercion
523    preflight_gated_check(repo_id.as_str(), config).await?;
524
525    // BORROW: explicit .as_str() instead of Deref coercion
526    let repo = build_model_repo(repo_id.as_str(), config)?;
527    download::download_all_files_map(repo, repo_id, Some(config)).await
528}
529
530/// Blocking version of [`download_files()`] for non-async callers.
531///
532/// Creates a Tokio runtime internally. Do not call from within
533/// an existing async context (use [`download_files()`] instead).
534///
535/// # Errors
536///
537/// Same as [`download_files()`].
538pub fn download_files_blocking(
539    repo_id: String,
540) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
541    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
542        path: PathBuf::from("<runtime>"),
543        source: e,
544    })?;
545    rt.block_on(download_files(repo_id))
546}
547
548/// Downloads a single file from a `HuggingFace` model repository.
549///
550/// Returns the local cache path. If the file is already cached (and
551/// checksums match when `verify_checksums` is enabled), the download
552/// is skipped and the cached path is returned immediately.
553///
554/// Files at or above [`FetchConfig`]'s `chunk_threshold` (auto-tuned by
555/// the download plan optimizer, or 100 MiB fallback) are downloaded using
556/// multiple parallel HTTP Range connections (`connections_per_file`,
557/// auto-tuned or 8 fallback). Smaller files use a single connection.
558///
559/// # Arguments
560///
561/// * `repo_id` — Repository identifier (e.g., `"mntss/clt-gemma-2-2b-426k"`).
562/// * `filename` — Exact filename within the repository (e.g., `"W_enc_5.safetensors"`).
563/// * `config` — Shared configuration for auth, progress, checksums, retries, and chunking.
564///
565/// # Errors
566///
567/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
568/// * [`FetchError::Http`] — if the file does not exist in the repository.
569/// * [`FetchError::Api`] — on download failure (after retries).
570/// * [`FetchError::Checksum`] — if verification is enabled and fails.
571pub async fn download_file(
572    repo_id: String,
573    filename: &str,
574    config: &FetchConfig,
575) -> Result<DownloadOutcome<PathBuf>, FetchError> {
576    // BORROW: explicit .as_str() instead of Deref coercion
577    preflight_gated_check(repo_id.as_str(), config).await?;
578
579    // BORROW: explicit .as_str() instead of Deref coercion
580    let repo = build_model_repo(repo_id.as_str(), config)?;
581    download::download_file_by_name(repo, repo_id, filename, config).await
582}
583
584/// Blocking version of [`download_file()`] for non-async callers.
585///
586/// Creates a Tokio runtime internally. Do not call from within
587/// an existing async context (use [`download_file()`] instead).
588///
589/// # Errors
590///
591/// Same as [`download_file()`].
592pub fn download_file_blocking(
593    repo_id: String,
594    filename: &str,
595    config: &FetchConfig,
596) -> Result<DownloadOutcome<PathBuf>, FetchError> {
597    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
598        path: PathBuf::from("<runtime>"),
599        source: e,
600    })?;
601    rt.block_on(download_file(repo_id, filename, config))
602}
603
604/// Blocking version of [`download_files_with_config()`] for non-async callers.
605///
606/// Creates a Tokio runtime internally. Do not call from within
607/// an existing async context (use [`download_files_with_config()`] instead).
608///
609/// # Errors
610///
611/// Same as [`download_files_with_config()`].
612pub fn download_files_with_config_blocking(
613    repo_id: String,
614    config: &FetchConfig,
615) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
616    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
617        path: PathBuf::from("<runtime>"),
618        source: e,
619    })?;
620    rt.block_on(download_files_with_config(repo_id, config))
621}
622
623/// Downloads files according to an existing [`DownloadPlan`].
624///
625/// Only uncached files in the plan are downloaded. The `config` controls
626/// authentication, progress, timeouts, and performance settings.
627/// Use [`DownloadPlan::recommended_config()`] to compute an optimized config,
628/// or override specific fields via [`DownloadPlan::recommended_config_builder()`].
629///
630/// # Errors
631///
632/// Returns [`FetchError::Io`] if the cache directory cannot be resolved.
633/// Same error conditions as [`download_with_config()`] for the download itself.
634pub async fn download_with_plan(
635    plan: &DownloadPlan,
636    config: &FetchConfig,
637) -> Result<DownloadOutcome<PathBuf>, FetchError> {
638    if plan.fully_cached() {
639        // Resolve snapshot path from cache and return immediately.
640        let cache_dir = config
641            .output_dir
642            .clone()
643            .map_or_else(cache::hf_cache_dir, Ok)?;
644        let repo_dir = cache_layout::repo_dir(&cache_dir, plan.repo_id.as_str());
645        let snapshot_dir = cache_layout::snapshot_dir(&repo_dir, plan.revision.as_str());
646        return Ok(DownloadOutcome::Cached(snapshot_dir));
647    }
648
649    // Delegate to the standard download path which will re-check cache
650    // internally. The plan's value is the dry-run preview and the
651    // recommended config computed by the caller.
652    // BORROW: explicit .clone() for owned String argument
653    download_with_config(plan.repo_id.clone(), config).await
654}
655
656/// Blocking version of [`download_with_plan()`] for non-async callers.
657///
658/// Creates a Tokio runtime internally. Do not call from within
659/// an existing async context (use [`download_with_plan()`] instead).
660///
661/// # Errors
662///
663/// Same as [`download_with_plan()`].
664pub fn download_with_plan_blocking(
665    plan: &DownloadPlan,
666    config: &FetchConfig,
667) -> Result<DownloadOutcome<PathBuf>, FetchError> {
668    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
669        path: PathBuf::from("<runtime>"),
670        source: e,
671    })?;
672    rt.block_on(download_with_plan(plan, config))
673}