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 ([`inspect::inspect_npz`] / [`inspect::inspect_safetensors`] /
124//! [`inspect::inspect_gguf`] each drive an anamnesis reader-based parser
125//! over an [`HttpRangeReader`] — see the [`http_range`] module for the
126//! substrate: tail prefetch, read-ahead, hard transfer budgets, token-free
127//! CDN requests). `PTH` remains cached-only (planned for v0.11.3) and
128//! errors early with a "pass --cached after downloading" recovery hint.
129//!
130//! For discovery — "what tensor files does this cached repo hold?" —
131//! [`inspect::list_cached_tensor_files`] (v0.10.5) enumerates
132//! `(filename, size)` pairs across all four formats without parsing any
133//! headers, with [`inspect::is_supported_tensor_file`] /
134//! [`inspect::SUPPORTED_TENSOR_EXTENSIONS`] as the shared extension
135//! predicate. The `.safetensors`-only [`inspect::list_cached_safetensors`]
136//! (v0.9.7) remains for callers that want exactly that subset. These back
137//! the CLI's `inspect --list`, numeric-index, and `--pick` flows.
138//!
139//! ## `HuggingFace` Cache
140//!
141//! Downloaded files are stored in the standard `HuggingFace` cache directory
142//! (`~/.cache/huggingface/hub/`), ensuring compatibility with Python tooling.
143//!
144//! ## Cache Management
145//!
146//! v0.10.0 adds library APIs for inspecting, verifying, and pruning the local
147//! cache. [`cache::cache_summary`] enumerates every cached repo with size and
148//! file counts; [`cache::repo_status`] gives a per-file `Complete` / `Partial` /
149//! `Missing` / `Excluded` breakdown for one repo (since v0.10.5, partials are
150//! attributed per-file via each file's own `blobs/<sha256>.chunked.part` temp
151//! blob rather than a repo-level heuristic); [`cache::verify_cache`] re-checks
152//! `SHA256` digests of cached files against `HuggingFace` LFS metadata; and
153//! [`cache::find_partial_files`] locates `.chunked.part` orphans from
154//! interrupted downloads.
155//!
156//! For long verifications (multi-GiB safetensors files), drive
157//! [`cache::verify_cache_with_progress`] with an [`Fn`] callback that receives
158//! [`cache::VerifyEvent`]s so a CLI or GUI can render a spinner or progress
159//! bar without polling.
160//!
161//! ```rust,no_run
162//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
163//! use hf_fetch_model::cache::{self, VerifyStatus};
164//!
165//! let results = cache::verify_cache("google/gemma-2-2b-it", None, None).await?;
166//! let ok = results
167//!     .iter()
168//!     .filter(|r| matches!(r.status, VerifyStatus::Ok))
169//!     .count();
170//! let mismatch = results
171//!     .iter()
172//!     .filter(|r| matches!(r.status, VerifyStatus::Mismatch { .. }))
173//!     .count();
174//! println!("{}/{} files verified, {} mismatches", ok, results.len(), mismatch);
175//! # Ok(())
176//! # }
177//! ```
178//!
179//! ## Download Durability
180//!
181//! Multi-connection downloads survive interruption. When a download is
182//! aborted by [`FetchConfigBuilder::timeout_per_file`] (default 300 s),
183//! Ctrl-C, panic, or a transient chunk error, the partial `.chunked.part`
184//! file plus a small per-chunk progress sidecar are kept on disk. The next
185//! call to [`download_with_config`] for the same file picks up where it
186//! stopped — each parallel chunk sends a fresh `Range` request that skips
187//! the bytes it already has — provided the upstream etag still matches.
188//! On etag change, schema-version mismatch, or a different
189//! [`FetchConfigBuilder::connections_per_file`] count, the partial is
190//! discarded and a fresh download starts.
191//!
192//! For slow connections on multi-GiB files, raise the per-file budget to
193//! match real throughput:
194//!
195//! ```rust,no_run
196//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
197//! use std::time::Duration;
198//! use hf_fetch_model::FetchConfig;
199//!
200//! let config = FetchConfig::builder()
201//!     .timeout_per_file(Duration::from_secs(1800))
202//!     .build()?;
203//! # let _ = hf_fetch_model::download_with_config(
204//! #     "google/gemma-4-E2B-it".to_owned(),
205//! #     &config,
206//! # ).await?;
207//! # Ok(())
208//! # }
209//! ```
210//!
211//! ## Authentication
212//!
213//! Set the `HF_TOKEN` environment variable to access private or gated models,
214//! or use [`FetchConfig::builder().token()`](FetchConfigBuilder::token).
215//!
216//! Gated repos (Meta Llama, Google Gemma, …) additionally require accepting
217//! the license on the model's `HuggingFace` page — once per gated family
218//! (a Llama 3.2 grant does not cover Llama 3.1). [`download()`] /
219//! [`download_with_config`] pre-flight the gate and return
220//! [`FetchError::Auth`] with the license URL before any transfer starts.
221//! The library-level [`inspect`] functions surface the underlying HTTP
222//! `401` / `403` as [`FetchError::Http`] instead — note that the Hub serves
223//! a gated repo's *metadata* publicly, so file listings succeed while
224//! content requests fail. The `hf-fm` CLI upgrades such `inspect` / `diff`
225//! failures into the same gated-model diagnosis the download pre-flight
226//! emits (v0.10.5).
227
228pub mod cache;
229pub mod cache_layout;
230pub mod checksum;
231mod chunked;
232mod chunked_state;
233pub mod config;
234pub mod discover;
235pub mod download;
236pub mod error;
237pub mod http_range;
238pub mod inspect;
239pub mod plan;
240pub mod progress;
241pub mod repo;
242mod retry;
243
244pub use chunked::build_client;
245pub use config::{
246    compile_glob_patterns, file_matches, has_glob_chars, FetchConfig, FetchConfigBuilder, Filter,
247};
248pub use discover::{DiscoveredFamily, GateStatus, ModelCardMetadata, SearchResult};
249pub use download::DownloadOutcome;
250pub use error::{FetchError, FileFailure};
251pub use http_range::{HttpRangeReader, RangeFetcher, RangeReader, RangeStats};
252pub use inspect::{AdapterConfig, ModelConfig};
253pub use plan::{download_plan, DownloadPlan, FilePlan};
254pub use progress::{ProgressEvent, ProgressReceiver};
255
256use std::collections::HashMap;
257use std::path::PathBuf;
258
259use hf_hub::{Repo, RepoType};
260
261/// Pre-flight check for gated model access.
262///
263/// Two cases:
264/// - **No token**: checks the model metadata (unauthenticated) for gating
265///   status and rejects with a clear message if gated.
266/// - **Token present**: if the model is gated, makes one authenticated
267///   metadata request to verify the token actually grants access. Catches
268///   invalid tokens and unaccepted licenses before the download starts.
269///
270/// If the metadata request itself fails (network error, private repo),
271/// the check is silently skipped so that normal download error handling
272/// can take over.
273async fn preflight_gated_check(repo_id: &str, config: &FetchConfig) -> Result<(), FetchError> {
274    // Best-effort: if the metadata call fails, let the download proceed.
275    let Ok(metadata) = discover::fetch_model_card(repo_id).await else {
276        return Ok(());
277    };
278
279    if !metadata.gated.is_gated() {
280        return Ok(());
281    }
282
283    // Model is gated — check auth.
284    if config.token.is_none() {
285        return Err(FetchError::Auth {
286            reason: format!(
287                "{repo_id} is a gated model — accept the license at \
288                 https://huggingface.co/{repo_id} and set HF_TOKEN or pass --token"
289            ),
290        });
291    }
292
293    // Token is present — verify it grants access with a lightweight probe.
294    let probe_client = chunked::build_client(config.token.as_deref())?;
295    let probe = repo::list_repo_files_with_metadata(
296        repo_id,
297        config.token.as_deref(),
298        config.revision.as_deref(),
299        &probe_client,
300    )
301    .await;
302
303    if let Err(ref e) = probe {
304        // BORROW: explicit .to_string() for error Display formatting
305        let msg = e.to_string();
306        if msg.contains("401") || msg.contains("403") {
307            return Err(FetchError::Auth {
308                reason: format!(
309                    "{repo_id} is a gated model and your token was rejected — \
310                     accept the license at https://huggingface.co/{repo_id} \
311                     and check that your token is valid"
312                ),
313            });
314        }
315    }
316
317    Ok(())
318}
319
320/// Downloads all files from a `HuggingFace` model repository.
321///
322/// Uses high-throughput mode for maximum download speed, including
323/// auto-tuned concurrency, chunked multi-connection downloads for large
324/// files, and plan-optimized settings based on file size distribution.
325/// Files are stored in the standard `HuggingFace` cache layout
326/// (`~/.cache/huggingface/hub/`).
327///
328/// Authentication is handled via the `HF_TOKEN` environment variable when set.
329///
330/// For filtering, progress, and other options, use [`download_with_config()`].
331///
332/// # Arguments
333///
334/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
335///
336/// # Returns
337///
338/// The path to the snapshot directory containing all downloaded files.
339///
340/// # Errors
341///
342/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
343/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
344/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
345/// * [`FetchError::InvalidPattern`] — if the default config fails to build (should not happen).
346pub async fn download(repo_id: String) -> Result<DownloadOutcome<PathBuf>, FetchError> {
347    let config = FetchConfig::builder().build()?;
348    download_with_config(repo_id, &config).await
349}
350
351/// Downloads files from a `HuggingFace` model repository using the given configuration.
352///
353/// Supports filtering, progress reporting, custom revision, authentication,
354/// and concurrency settings via [`FetchConfig`].
355///
356/// # Arguments
357///
358/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
359/// * `config` — Download configuration (see [`FetchConfig::builder()`]).
360///
361/// # Returns
362///
363/// The path to the snapshot directory containing all downloaded files.
364///
365/// # Errors
366///
367/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
368/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
369/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
370pub async fn download_with_config(
371    repo_id: String,
372    config: &FetchConfig,
373) -> Result<DownloadOutcome<PathBuf>, FetchError> {
374    // BORROW: explicit .as_str() instead of Deref coercion
375    preflight_gated_check(repo_id.as_str(), config).await?;
376
377    let mut builder = hf_hub::api::tokio::ApiBuilder::new().high();
378
379    if let Some(ref token) = config.token {
380        // BORROW: explicit .clone() to pass owned String
381        builder = builder.with_token(Some(token.clone()));
382    }
383
384    if let Some(ref dir) = config.output_dir {
385        // BORROW: explicit .clone() for owned PathBuf
386        builder = builder.with_cache_dir(dir.clone());
387    }
388
389    let api = builder.build().map_err(FetchError::Api)?;
390
391    let hf_repo = match config.revision {
392        Some(ref rev) => {
393            // BORROW: explicit .clone() for owned String arguments
394            Repo::with_revision(repo_id.clone(), RepoType::Model, rev.clone())
395        }
396        None => Repo::new(repo_id.clone(), RepoType::Model),
397    };
398
399    let repo = api.repo(hf_repo);
400    download::download_all_files(repo, repo_id, Some(config)).await
401}
402
403/// Blocking version of [`download()`] for non-async callers.
404///
405/// Creates a Tokio runtime internally. Do not call from within
406/// an existing async context (use [`download()`] instead).
407///
408/// # Errors
409///
410/// Same as [`download()`].
411pub fn download_blocking(repo_id: String) -> Result<DownloadOutcome<PathBuf>, FetchError> {
412    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
413        path: PathBuf::from("<runtime>"),
414        source: e,
415    })?;
416    rt.block_on(download(repo_id))
417}
418
419/// Blocking version of [`download_with_config()`] for non-async callers.
420///
421/// Creates a Tokio runtime internally. Do not call from within
422/// an existing async context (use [`download_with_config()`] instead).
423///
424/// # Errors
425///
426/// Same as [`download_with_config()`].
427pub fn download_with_config_blocking(
428    repo_id: String,
429    config: &FetchConfig,
430) -> Result<DownloadOutcome<PathBuf>, FetchError> {
431    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
432        path: PathBuf::from("<runtime>"),
433        source: e,
434    })?;
435    rt.block_on(download_with_config(repo_id, config))
436}
437
438/// Downloads all files from a `HuggingFace` model repository and returns
439/// a filename → path map.
440///
441/// Each key is the relative filename within the repository (e.g.,
442/// `"config.json"`, `"model.safetensors"`), and each value is the
443/// absolute local path to the downloaded file.
444///
445/// Uses the same high-throughput defaults as [`download()`]: auto-tuned
446/// concurrency and chunked multi-connection downloads for large files.
447///
448/// For filtering, progress, and other options, use
449/// [`download_files_with_config()`].
450///
451/// # Arguments
452///
453/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
454///
455/// # Errors
456///
457/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
458/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
459/// * [`FetchError::InvalidPattern`] — if the default config fails to build (should not happen).
460pub async fn download_files(
461    repo_id: String,
462) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
463    let config = FetchConfig::builder().build()?;
464    download_files_with_config(repo_id, &config).await
465}
466
467/// Downloads files from a `HuggingFace` model repository using the given
468/// configuration and returns a filename → path map.
469///
470/// Each key is the relative filename within the repository (e.g.,
471/// `"config.json"`, `"model.safetensors"`), and each value is the
472/// absolute local path to the downloaded file.
473///
474/// # Arguments
475///
476/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
477/// * `config` — Download configuration (see [`FetchConfig::builder()`]).
478///
479/// # Errors
480///
481/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
482/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
483/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
484pub async fn download_files_with_config(
485    repo_id: String,
486    config: &FetchConfig,
487) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
488    // BORROW: explicit .as_str() instead of Deref coercion
489    preflight_gated_check(repo_id.as_str(), config).await?;
490
491    let mut builder = hf_hub::api::tokio::ApiBuilder::new().high();
492
493    if let Some(ref token) = config.token {
494        // BORROW: explicit .clone() to pass owned String
495        builder = builder.with_token(Some(token.clone()));
496    }
497
498    if let Some(ref dir) = config.output_dir {
499        // BORROW: explicit .clone() for owned PathBuf
500        builder = builder.with_cache_dir(dir.clone());
501    }
502
503    let api = builder.build().map_err(FetchError::Api)?;
504
505    let hf_repo = match config.revision {
506        Some(ref rev) => {
507            // BORROW: explicit .clone() for owned String arguments
508            Repo::with_revision(repo_id.clone(), RepoType::Model, rev.clone())
509        }
510        None => Repo::new(repo_id.clone(), RepoType::Model),
511    };
512
513    let repo = api.repo(hf_repo);
514    download::download_all_files_map(repo, repo_id, Some(config)).await
515}
516
517/// Blocking version of [`download_files()`] for non-async callers.
518///
519/// Creates a Tokio runtime internally. Do not call from within
520/// an existing async context (use [`download_files()`] instead).
521///
522/// # Errors
523///
524/// Same as [`download_files()`].
525pub fn download_files_blocking(
526    repo_id: String,
527) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
528    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
529        path: PathBuf::from("<runtime>"),
530        source: e,
531    })?;
532    rt.block_on(download_files(repo_id))
533}
534
535/// Downloads a single file from a `HuggingFace` model repository.
536///
537/// Returns the local cache path. If the file is already cached (and
538/// checksums match when `verify_checksums` is enabled), the download
539/// is skipped and the cached path is returned immediately.
540///
541/// Files at or above [`FetchConfig`]'s `chunk_threshold` (auto-tuned by
542/// the download plan optimizer, or 100 MiB fallback) are downloaded using
543/// multiple parallel HTTP Range connections (`connections_per_file`,
544/// auto-tuned or 8 fallback). Smaller files use a single connection.
545///
546/// # Arguments
547///
548/// * `repo_id` — Repository identifier (e.g., `"mntss/clt-gemma-2-2b-426k"`).
549/// * `filename` — Exact filename within the repository (e.g., `"W_enc_5.safetensors"`).
550/// * `config` — Shared configuration for auth, progress, checksums, retries, and chunking.
551///
552/// # Errors
553///
554/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
555/// * [`FetchError::Http`] — if the file does not exist in the repository.
556/// * [`FetchError::Api`] — on download failure (after retries).
557/// * [`FetchError::Checksum`] — if verification is enabled and fails.
558pub async fn download_file(
559    repo_id: String,
560    filename: &str,
561    config: &FetchConfig,
562) -> Result<DownloadOutcome<PathBuf>, FetchError> {
563    // BORROW: explicit .as_str() instead of Deref coercion
564    preflight_gated_check(repo_id.as_str(), config).await?;
565
566    let mut builder = hf_hub::api::tokio::ApiBuilder::new().high();
567
568    if let Some(ref token) = config.token {
569        // BORROW: explicit .clone() to pass owned String
570        builder = builder.with_token(Some(token.clone()));
571    }
572
573    if let Some(ref dir) = config.output_dir {
574        // BORROW: explicit .clone() for owned PathBuf
575        builder = builder.with_cache_dir(dir.clone());
576    }
577
578    let api = builder.build().map_err(FetchError::Api)?;
579
580    let hf_repo = match config.revision {
581        Some(ref rev) => {
582            // BORROW: explicit .clone() for owned String arguments
583            Repo::with_revision(repo_id.clone(), RepoType::Model, rev.clone())
584        }
585        None => Repo::new(repo_id.clone(), RepoType::Model),
586    };
587
588    let repo = api.repo(hf_repo);
589    download::download_file_by_name(repo, repo_id, filename, config).await
590}
591
592/// Blocking version of [`download_file()`] for non-async callers.
593///
594/// Creates a Tokio runtime internally. Do not call from within
595/// an existing async context (use [`download_file()`] instead).
596///
597/// # Errors
598///
599/// Same as [`download_file()`].
600pub fn download_file_blocking(
601    repo_id: String,
602    filename: &str,
603    config: &FetchConfig,
604) -> Result<DownloadOutcome<PathBuf>, FetchError> {
605    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
606        path: PathBuf::from("<runtime>"),
607        source: e,
608    })?;
609    rt.block_on(download_file(repo_id, filename, config))
610}
611
612/// Blocking version of [`download_files_with_config()`] for non-async callers.
613///
614/// Creates a Tokio runtime internally. Do not call from within
615/// an existing async context (use [`download_files_with_config()`] instead).
616///
617/// # Errors
618///
619/// Same as [`download_files_with_config()`].
620pub fn download_files_with_config_blocking(
621    repo_id: String,
622    config: &FetchConfig,
623) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
624    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
625        path: PathBuf::from("<runtime>"),
626        source: e,
627    })?;
628    rt.block_on(download_files_with_config(repo_id, config))
629}
630
631/// Downloads files according to an existing [`DownloadPlan`].
632///
633/// Only uncached files in the plan are downloaded. The `config` controls
634/// authentication, progress, timeouts, and performance settings.
635/// Use [`DownloadPlan::recommended_config()`] to compute an optimized config,
636/// or override specific fields via [`DownloadPlan::recommended_config_builder()`].
637///
638/// # Errors
639///
640/// Returns [`FetchError::Io`] if the cache directory cannot be resolved.
641/// Same error conditions as [`download_with_config()`] for the download itself.
642pub async fn download_with_plan(
643    plan: &DownloadPlan,
644    config: &FetchConfig,
645) -> Result<DownloadOutcome<PathBuf>, FetchError> {
646    if plan.fully_cached() {
647        // Resolve snapshot path from cache and return immediately.
648        let cache_dir = config
649            .output_dir
650            .clone()
651            .map_or_else(cache::hf_cache_dir, Ok)?;
652        let repo_dir = cache_layout::repo_dir(&cache_dir, plan.repo_id.as_str());
653        let snapshot_dir = cache_layout::snapshot_dir(&repo_dir, plan.revision.as_str());
654        return Ok(DownloadOutcome::Cached(snapshot_dir));
655    }
656
657    // Delegate to the standard download path which will re-check cache
658    // internally. The plan's value is the dry-run preview and the
659    // recommended config computed by the caller.
660    // BORROW: explicit .clone() for owned String argument
661    download_with_config(plan.repo_id.clone(), config).await
662}
663
664/// Blocking version of [`download_with_plan()`] for non-async callers.
665///
666/// Creates a Tokio runtime internally. Do not call from within
667/// an existing async context (use [`download_with_plan()`] instead).
668///
669/// # Errors
670///
671/// Same as [`download_with_plan()`].
672pub fn download_with_plan_blocking(
673    plan: &DownloadPlan,
674    config: &FetchConfig,
675) -> Result<DownloadOutcome<PathBuf>, FetchError> {
676    let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
677        path: PathBuf::from("<runtime>"),
678        source: e,
679    })?;
680    rt.block_on(download_with_plan(plan, config))
681}