Skip to main content

acorn/io/api/
huggingface.rs

1//! Hugging Face Hub API helpers.
2use super::{Endpoint, IntoHeaders, Params, RemoteResource, ResponseContent, TreeEntry};
3use crate::io::api::{Param, RepositoryFileMetadata};
4use crate::io::config::FilterSet;
5use crate::io::download::{DownloadItem, DownloadItems};
6use crate::io::sync::Shard;
7use crate::io::{first_env_var, http, ApiResult, Source};
8use crate::prelude::{HashMap, Path};
9use crate::schema::agent::{ModelDetails, ModelSelector, Quantization, Weight, Weights, FALLBACK_MODEL_SUFFIXES};
10use crate::util::constants::app::{
11    DEFAULT_HUGGINGFACE_DOMAIN, DEFAULT_HUGGINGFACE_MINIMUM_DOWNLOAD_COUNT, DEFAULT_HUGGINGFACE_MODEL_REVISION, DEFAULT_HUGGINGFACE_SEARCH_LIMIT,
12    DEFAULT_HUGGINGFACE_SEARCH_TERM,
13};
14use crate::util::constants::env::HUGGINGFACE_TOKEN_VARIABLE_NAMES;
15use crate::util::{glob_matches, regex_to_glob, strip_suffixes, to_ascii_alphanumeric, Label};
16use crate::{Location, Repository};
17use alloc::boxed::Box;
18use alloc::collections::BTreeSet;
19use async_trait::async_trait;
20use axum::http::HeaderMap;
21use bon::Builder;
22use color_eyre::eyre::{eyre, Report};
23use core::{cmp::Reverse, fmt, ops::Deref};
24use futures::{future::join_all, TryStreamExt};
25use hf_hub::{repository::ModelInfo, HFClient, HFError};
26use owo_colors::OwoColorize;
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29use tracing::{info, warn};
30
31/// Candidate repositories returned by model search.
32pub type Candidates = Vec<Candidate>;
33/// Function used to select from multiple interactive candidates.
34pub type CandidateSelector = fn(Candidates, &Options) -> ApiResult<String>;
35/// Select a repository from model search candidates.
36pub trait CandidateSelection {
37    /// Build eligible fallback candidates from Hugging Face model metadata.
38    fn fallback(models: Vec<ModelInfo>, options: &SearchOptions) -> Self;
39    /// Select a candidate using the selector configured in `options` when interaction is required.
40    fn select(self, options: &Options) -> ApiResult<String>;
41    /// Fallback selector used when the interactive picker is unavailable.
42    ///
43    /// Picks the most-downloaded candidate and warns the user.
44    fn select_interactively(self, options: &Options) -> ApiResult<String>;
45}
46/// Trait for selecting and filtering files from a Hugging Face repository.
47#[async_trait]
48pub trait HuggingFaceRepository {
49    /// Resolve SHA-256 checksums for repository files from `.sha256` sidecar files
50    async fn checksums(&self, identifier: &str, revision: &str) -> ApiResult<HashMap<String, String>>;
51    /// Download files and return the selected repository metadata
52    async fn download(&self, options: &Options) -> ApiResult<Downloaded>;
53    /// Filter repository files using glob patterns for include/exclude.
54    fn filter(&self, filter: Option<&str>, ignore: Option<&str>) -> ApiResult<Self>
55    where
56        Self: Sized;
57    /// Select preferred files based on a marker pattern, erroring on ambiguity.
58    fn select(&self, policy: &FileSelectionPolicy<'_>) -> ApiResult<Self>
59    where
60        Self: Sized;
61    /// Determine whether a failed download should trigger GGUF repository fallback discovery.
62    fn should_use_fallback(&self, options: &Options) -> bool;
63    /// Try to filter repository files using glob patterns, returning `None` when regex fallback is required.
64    fn try_glob(&self, filter: Option<&str>, ignore: Option<&str>) -> Option<Self>
65    where
66        Self: Sized;
67}
68/// Extension trait for Hugging Face model metadata
69pub trait ModelInfoExtension {
70    /// Determine whether a Hugging Face model repository contains GGUF files
71    fn has_gguf_files(&self) -> bool;
72    /// Determine whether a Hugging Face model is an eligible fallback for an identifier.
73    fn is_fallback_for(&self, identifier: &str) -> bool;
74    /// Determine whether a Hugging Face model declares a given model as its exact base model.
75    ///
76    /// For example, a GGUF repository declaring `openai/gpt-oss-20b` is a derivative of
77    /// `openai/gpt-oss-20b`.
78    fn is_declared_derivative_of(&self, identifier: &str) -> bool;
79    /// Determine whether a Hugging Face model declares a decorated variant of a given base model.
80    ///
81    /// For example, a GGUF repository declaring `nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16`
82    /// is a variant of `nvidia/nemotron-3-super-120b-a12b`.
83    fn is_declared_variant_of(&self, identifier: &str) -> bool;
84}
85/// Errors produced by Hugging Face repository selection and download helpers
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub enum HuggingFaceError {
88    /// No GGUF model files were present in the repository tree
89    NoGgufModelFiles,
90    /// No GGUF quantization repository declares the requested base model
91    NoGgufQuantizationRepository {
92        /// Requested Hugging Face base-model identifier
93        identifier: Box<str>,
94    },
95    /// The requested Hugging Face base-model identifier is invalid
96    InvalidBaseModelIdentifier {
97        /// Invalid Hugging Face base-model identifier
98        identifier: Box<str>,
99    },
100    /// The Hugging Face client could not be initialized
101    ClientInitializationFailed {
102        /// Underlying client initialization error
103        reason: Box<str>,
104    },
105    /// The Hugging Face model search could not be configured
106    ModelSearchConfigurationFailed {
107        /// Underlying search configuration error
108        reason: Box<str>,
109    },
110    /// The Hugging Face model search failed while reading results
111    ModelSearchFailed {
112        /// Underlying model search error
113        reason: Box<str>,
114    },
115}
116/// GGUF repository metadata returned by fallback discovery.
117#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
118pub struct Candidate {
119    /// Hugging Face repository identifier
120    pub id: String,
121    /// Downloads during the repository's reported period
122    pub downloads: u64,
123    /// Repository like count
124    pub likes: Option<u64>,
125    /// Quantization formats detected in repository filenames
126    pub quantizations: Vec<String>,
127}
128/// A repository-backed value together with its requested and resolved identifiers.
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct RepositoryResolution<T> {
131    requested: String,
132    resolved: String,
133    value: T,
134}
135/// Hugging Face repository files selected and downloaded for a model
136#[derive(Builder, Clone, Debug, Eq, PartialEq)]
137#[builder(start_fn = init, on(String, into))]
138pub struct Downloaded {
139    /// Hugging Face repository identifier
140    pub identifier: String,
141    /// Repository revision used for the download
142    pub revision: String,
143    /// Repository-relative paths downloaded from the repository
144    pub files: Vec<String>,
145}
146/// Policy for selecting a preferred file when multiple candidates exist.
147#[derive(Clone, Debug)]
148pub struct FileSelectionPolicy<'a> {
149    /// Substring to match in the filename (case-insensitive) to identify the preferred file.
150    pub preferred_marker: &'a str,
151    /// Error message to display when no files match.
152    pub no_match_message: &'a str,
153}
154/// Options for Hugging Face model fallback searches.
155#[derive(Builder, Clone, Debug)]
156#[builder(start_fn = init, on(String, into))]
157pub struct SearchOptions {
158    /// Base model identifier used to find related GGUF repositories.
159    pub identifier: String,
160    /// Hugging Face search filter.
161    #[builder(default = DEFAULT_HUGGINGFACE_SEARCH_TERM.to_string())]
162    pub term: String,
163    /// Maximum number of repositories considered during fallback discovery.
164    #[builder(default = DEFAULT_HUGGINGFACE_SEARCH_LIMIT)]
165    pub limit: usize,
166    /// Minimum number of downloads required for a fallback repository.
167    #[builder(default = DEFAULT_HUGGINGFACE_MINIMUM_DOWNLOAD_COUNT)]
168    pub minimum_download_count: u64,
169    /// Whether fallback candidates should be selected interactively.
170    #[builder(default)]
171    pub interactive: bool,
172}
173/// Options for HuggingFace API requests
174#[derive(Builder, Clone, Debug)]
175#[builder(start_fn = init, on(String, into))]
176pub struct Options {
177    /// Authentication token
178    pub token: Option<String>,
179    /// Model registry domain (default: "huggingface.co")
180    #[builder(default = String::from("huggingface.co"))]
181    pub domain: String,
182    /// Model repository identifier
183    pub identifier: Option<String>,
184    /// Model repository revision (branch, tag, or commit)
185    #[builder(default = String::from(DEFAULT_HUGGINGFACE_MODEL_REVISION))]
186    pub revision: String,
187    /// Repository path used for tree requests
188    pub path: Option<String>,
189    /// Regex pattern of files to include at a given path desginated by `path`
190    pub filter: Option<String>,
191    /// Regex pattern of files to ignore at a given path desginated by `path`
192    pub ignore: Option<String>,
193    /// Flag used to suppress output
194    #[builder(default)]
195    pub quiet: bool,
196    /// Whether network-backed fallback discovery is disabled by offline mode
197    #[builder(default)]
198    pub offline: bool,
199    /// Whether automatic GGUF quantization repository discovery is disabled
200    #[builder(default)]
201    pub no_fallback: bool,
202    /// Maximum number of repositories considered during GGUF fallback discovery
203    #[builder(default = DEFAULT_HUGGINGFACE_SEARCH_LIMIT)]
204    pub search_limit: usize,
205    /// Minimum number of downloads required for a GGUF fallback repository
206    #[builder(default = DEFAULT_HUGGINGFACE_MINIMUM_DOWNLOAD_COUNT)]
207    pub minimum_download_count: u64,
208    /// Whether multiple GGUF fallback repositories should be selected interactively
209    #[builder(default)]
210    pub interactive: bool,
211    /// Selector used when multiple repositories require interactive selection
212    #[builder(default = select_first)]
213    pub selector: CandidateSelector,
214    /// Custom API parameters to include in every request
215    #[builder(default = vec![])]
216    pub custom_params: Vec<Param>,
217    /// Skip SHA-256 checksum verification after download
218    #[builder(default)]
219    pub skip_verify_checksum: bool,
220    /// Output directory for downloaded files
221    pub output: Option<String>,
222}
223/// File metadata returned by the Hugging Face repository tree API
224#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct HuggingFaceRepositoryFile {
226    /// Repository-relative path
227    pub path: String,
228    /// File size in bytes, when provided by Hugging Face
229    pub size: Option<u64>,
230}
231/// Repository identity and files returned by the Hugging Face tree API.
232#[derive(Clone, Debug, PartialEq, Eq)]
233pub struct HuggingFaceRepositoryFiles {
234    /// Repository-relative file metadata.
235    pub files: Vec<HuggingFaceRepositoryFile>,
236    /// Hugging Face repository identifier.
237    pub identifier: String,
238    /// Repository revision used for the tree request.
239    pub revision: String,
240}
241impl RepositoryFileMetadata for HuggingFaceRepositoryFile {
242    fn path(&self) -> &str {
243        &self.path
244    }
245    fn size(&self) -> Option<u64> {
246        self.size
247    }
248}
249impl HuggingFaceRepositoryFiles {
250    /// Create repository file metadata with its repository identity.
251    pub fn new(identifier: impl Into<String>, revision: impl Into<String>, files: Vec<HuggingFaceRepositoryFile>) -> Self {
252        Self {
253            files,
254            identifier: identifier.into(),
255            revision: revision.into(),
256        }
257    }
258    /// Returns a complete split GGUF shard set as one logical candidate.
259    /// Repository listings expose each shard as a separate file, which can otherwise look like multiple candidates.
260    fn complete_shard_set(&self) -> Option<Self> {
261        let shards = self
262            .iter()
263            .filter_map(|file| Shard::parts(&file.path.to_ascii_lowercase()))
264            .collect::<Vec<_>>();
265        shards.first().and_then(|(key, _, count)| {
266            let indexes = shards
267                .iter()
268                .filter(|(candidate_key, _, candidate_count)| candidate_key == key && candidate_count == count)
269                .map(|(_, index, _)| *index)
270                .collect::<BTreeSet<_>>();
271            let expected = (1..=*count).collect::<BTreeSet<_>>();
272            match indexes == expected && usize::try_from(*count).ok() == Some(shards.len()) {
273                | true => Some(self.clone()),
274                | false => None,
275            }
276        })
277    }
278    fn repository_model_files(&self) -> Self {
279        let repository_name = self.identifier.rsplit('/').next().unwrap_or_default().to_ascii_lowercase();
280        let model_name = repository_name.strip_suffix("-gguf").unwrap_or(&repository_name);
281        let prefix = format!("{model_name}-");
282        let files = self
283            .iter()
284            .filter(|file| {
285                file.path
286                    .rsplit('/')
287                    .next()
288                    .is_some_and(|filename| filename.to_ascii_lowercase().starts_with(&prefix))
289            })
290            .cloned()
291            .collect();
292        Self::new(&self.identifier, &self.revision, files)
293    }
294    fn candidate_report(&self) -> String {
295        match self.len() {
296            | 0 => "no GGUF files".to_string(),
297            | 1 | 2 => self.iter().map(|file| file.path.as_str()).collect::<Vec<_>>().join(", "),
298            | count => format!("{count} GGUF files"),
299        }
300    }
301    fn parse(content: &str, options: &Options) -> ApiResult<Self> {
302        #[derive(Deserialize)]
303        struct ApiError {
304            #[serde(alias = "message")]
305            error: String,
306        }
307        match options.identifier.as_deref() {
308            | Some(identifier) => serde_json::from_str::<Vec<TreeEntry>>(content)
309                .map(|entries| {
310                    let files = entries
311                        .into_iter()
312                        .filter(TreeEntry::is_file)
313                        .map(HuggingFaceRepositoryFile::from)
314                        .collect();
315                    Self::new(identifier, &options.revision, files)
316                })
317                .map_err(|why| {
318                    serde_json::from_str::<ApiError>(content).map_or_else(
319                        |_| eyre!("Failed to parse Hugging Face repository file list for '{identifier}' — {why}"),
320                        |response| eyre!("Hugging Face API rejected repository '{identifier}' — {}", response.error),
321                    )
322                }),
323            | None => Err(eyre!("Missing Hugging Face repository identifier")),
324        }
325    }
326    /// Resolve the configured repository or a selected GGUF fallback repository.
327    pub async fn resolve(options: &Options) -> ApiResult<RepositoryResolution<Self>> {
328        let Options {
329            identifier,
330            no_fallback,
331            revision,
332            search_limit,
333            minimum_download_count,
334            interactive,
335            ..
336        } = options;
337        match identifier.as_deref() {
338            | Some(identifier) => match repository_tree(identifier, revision).await {
339                | Ok(repository) if repository.files.iter().any(|file| Quantization::from_gguf_filename(&file.path).is_some()) => {
340                    Ok(RepositoryResolution::new(identifier, identifier, repository))
341                }
342                | Ok(_) if *no_fallback => Err(eyre!("No GGUF model files found for '{identifier}'")),
343                | Err(why) if *no_fallback => Err(why),
344                | Ok(_) | Err(_) => {
345                    let search_options = SearchOptions::init()
346                        .identifier(identifier)
347                        .limit(*search_limit)
348                        .minimum_download_count(*minimum_download_count)
349                        .interactive(*interactive)
350                        .build();
351                    match search(&search_options).await {
352                        | Ok(candidates) => match candidates.select(options) {
353                            | Ok(resolved) => match repository_tree(&resolved, DEFAULT_HUGGINGFACE_MODEL_REVISION).await {
354                                | Ok(repository) => Ok(RepositoryResolution::new(identifier, resolved, repository)),
355                                | Err(why) => Err(why),
356                            },
357                            | Err(why) => Err(why),
358                        },
359                        | Err(why) => Err(why),
360                    }
361                }
362            },
363            | None => Err(eyre!("Missing Hugging Face repository identifier")),
364        }
365    }
366}
367impl Deref for HuggingFaceRepositoryFiles {
368    type Target = [HuggingFaceRepositoryFile];
369    fn deref(&self) -> &Self::Target {
370        &self.files
371    }
372}
373impl IntoIterator for HuggingFaceRepositoryFiles {
374    type Item = HuggingFaceRepositoryFile;
375    type IntoIter = alloc::vec::IntoIter<HuggingFaceRepositoryFile>;
376    fn into_iter(self) -> Self::IntoIter {
377        self.files.into_iter()
378    }
379}
380impl From<HuggingFaceRepositoryFiles> for Weights {
381    fn from(repository: HuggingFaceRepositoryFiles) -> Self {
382        Weights(
383            repository
384                .files
385                .into_iter()
386                .filter_map(|file| {
387                    Quantization::from_gguf_filename(&file.path).map(|quantization| Weight {
388                        label: file.path.clone(),
389                        url: format!(
390                            "https://{DEFAULT_HUGGINGFACE_DOMAIN}/{}/resolve/{}/{}",
391                            repository.identifier, repository.revision, file.path
392                        ),
393                        is_open: None,
394                        quantization: Some(quantization),
395                        size: file.size,
396                    })
397                })
398                .collect(),
399        )
400    }
401}
402impl fmt::Display for Candidate {
403    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
404        formatter.write_str(&self.id)
405    }
406}
407impl From<ModelInfo> for Candidate {
408    fn from(model: ModelInfo) -> Self {
409        let quantizations = model
410            .siblings
411            .unwrap_or_default()
412            .iter()
413            .filter_map(|sibling| Quantization::from_gguf_filename(&sibling.rfilename).map(|quantization| quantization.to_string()))
414            .collect::<BTreeSet<_>>()
415            .into_iter()
416            .collect();
417        Self {
418            id: model.id,
419            downloads: model.downloads.unwrap_or_default(),
420            likes: model.likes,
421            quantizations,
422        }
423    }
424}
425impl CandidateSelection for Candidates {
426    fn fallback(models: Vec<ModelInfo>, options: &SearchOptions) -> Self {
427        let SearchOptions {
428            identifier,
429            minimum_download_count,
430            interactive,
431            ..
432        } = options;
433        let mut candidates = models
434            .into_iter()
435            .filter(|model| model.is_fallback_for(identifier))
436            .map(Candidate::from)
437            .filter(|candidate| !candidate.quantizations.is_empty())
438            .collect::<Vec<_>>();
439        candidates.sort_by_key(|candidate| Reverse(candidate.downloads));
440        let rejected = |candidate: &&Candidate| candidate.downloads < *minimum_download_count;
441        let report = |candidate: &Candidate| {
442            let Candidate { id, downloads, .. } = candidate;
443            let context = format!("{} {identifier}", "fallback from".italic());
444            let reason = format!("({downloads} below minimum {minimum_download_count} popularity)");
445            warn!("=> {}{} {} {}", Label::rejected(), id.yellow(), context.dimmed(), reason.dimmed(),);
446        };
447        match interactive {
448            | true => candidates.iter().filter(rejected).for_each(report),
449            | false => candidates.first().filter(rejected).into_iter().for_each(report),
450        }
451        candidates
452            .into_iter()
453            .filter(|candidate| candidate.downloads >= *minimum_download_count)
454            .collect()
455    }
456    fn select(self, options: &Options) -> ApiResult<String> {
457        let base_model = options.identifier.as_deref().unwrap_or_default();
458        match self.as_slice() {
459            | [] => Err(eyre!(HuggingFaceError::NoGgufQuantizationRepository {
460                identifier: base_model.into()
461            })),
462            | [candidate] => Ok(candidate.to_string()),
463            | [candidate, ..] if !options.interactive => {
464                if !options.quiet {
465                    info!(
466                        "=> {} {} {} {}",
467                        Label::using(),
468                        candidate.green(),
469                        format!("{} {base_model}", "fallback for".italic().dimmed()),
470                        "(using most popular)".dimmed(),
471                    );
472                }
473                Ok(candidate.to_string())
474            }
475            | _ => (options.selector)(self, options),
476        }
477    }
478    fn select_interactively(self, options: &Options) -> ApiResult<String> {
479        let candidate = self.first().ok_or_else(|| eyre!("GGUF search returned no candidates"))?;
480        if !options.quiet {
481            let reason = "(using most popular)";
482            warn!("=> {} {} {}", Label::using(), candidate.green(), reason.dimmed());
483        }
484        Ok(candidate.to_string())
485    }
486}
487impl Downloaded {
488    /// Wrap this download as a direct repository resolution.
489    pub fn into_resolution(self, identifier: impl Into<String>) -> RepositoryResolution<Self> {
490        RepositoryResolution::direct(identifier, self)
491    }
492    /// Merge downloaded GGUF quantizations into existing model weights.
493    pub fn merge_weights(&self, existing: Weights) -> Weights {
494        let downloaded = self
495            .files
496            .iter()
497            .filter_map(|path| {
498                let Self { identifier, revision, .. } = self;
499                Quantization::from_gguf_filename(path).map(|quantization| Weight {
500                    label: quantization.to_string(),
501                    url: format!("https://{DEFAULT_HUGGINGFACE_DOMAIN}/{identifier}/resolve/{revision}/{path}"),
502                    is_open: None,
503                    quantization: Some(quantization),
504                    size: None,
505                })
506            })
507            .filter(|candidate| {
508                !existing
509                    .0
510                    .iter()
511                    .any(|weight| weight.url == candidate.url && weight.quantization == candidate.quantization)
512            })
513            .collect::<Vec<_>>();
514        Weights(existing.0.into_iter().chain(downloaded).collect())
515    }
516}
517impl fmt::Display for HuggingFaceError {
518    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
519        match self {
520            | Self::NoGgufModelFiles => {
521                write!(
522                    formatter,
523                    "No GGUF model files found; use a GGUF repository or provide --filter for another format"
524                )
525            }
526            | Self::NoGgufQuantizationRepository { identifier } => {
527                write!(formatter, "no GGUF quantization repo found for {identifier}")
528            }
529            | Self::InvalidBaseModelIdentifier { identifier } => {
530                write!(formatter, "invalid Hugging Face base model identifier: {identifier}")
531            }
532            | Self::ClientInitializationFailed { reason } => {
533                write!(formatter, "failed to initialize Hugging Face client: {reason}")
534            }
535            | Self::ModelSearchConfigurationFailed { reason } => {
536                write!(formatter, "failed to configure Hugging Face model search: {reason}")
537            }
538            | Self::ModelSearchFailed { reason } => {
539                write!(formatter, "failed to search Hugging Face models: {reason}")
540            }
541        }
542    }
543}
544impl core::error::Error for HuggingFaceError {}
545impl From<TreeEntry> for HuggingFaceRepositoryFile {
546    fn from(entry: TreeEntry) -> Self {
547        HuggingFaceRepositoryFile {
548            path: entry.path,
549            size: entry.size,
550        }
551    }
552}
553#[async_trait]
554impl HuggingFaceRepository for HuggingFaceRepositoryFiles {
555    /// Resolve SHA-256 checksums for repository files by fetching `.sha256` sidecar files.
556    async fn checksums(&self, identifier: &str, revision: &str) -> ApiResult<HashMap<String, String>> {
557        let candidates: Vec<_> = self
558            .iter()
559            .filter_map(|file| target_path_from_sidecar(&file.path).map(|target| (file.path.clone(), target)))
560            .collect();
561        let digests = join_all(candidates.into_iter().map(|(path, target)| async move {
562            let url = format!("https://huggingface.co/{identifier}/resolve/{revision}/{path}");
563            let content = match http::get(url).headers(auth_headers()).send().await {
564                | Ok(response) => response.text().await.ok(),
565                | Err(_) => None,
566            };
567            (target, content.as_deref().and_then(extract_sha256))
568        }))
569        .await;
570        Ok(digests.into_iter().filter_map(|(target, digest)| digest.map(|d| (target, d))).collect())
571    }
572    async fn download(&self, options: &Options) -> ApiResult<Downloaded> {
573        let Options {
574            identifier,
575            revision,
576            filter,
577            ignore,
578            quiet,
579            skip_verify_checksum,
580            output,
581            ..
582        } = options;
583        match (identifier.as_deref(), output.as_deref()) {
584            | (Some(identifier), Some(output)) => match self.checksums(identifier, revision).await {
585                | Ok(lookup) => match self.filter(filter.as_deref(), ignore.as_deref()) {
586                    | Ok(files) => {
587                        let selected_files = files.iter().map(|file| file.path.clone()).collect();
588                        let items = files
589                            .into_iter()
590                            .map(|HuggingFaceRepositoryFile { path, size, .. }| {
591                                let sha = lookup.get(&path).cloned();
592                                let url = format!("https://huggingface.co/{identifier}/resolve/{revision}/{path}");
593                                DownloadItem { path, sha, size, url }
594                            })
595                            .collect::<Vec<_>>();
596                        DownloadItems::new(Path::new(output), items, *quiet, *skip_verify_checksum)
597                            .download()
598                            .await
599                            .map(|()| {
600                                Downloaded::init()
601                                    .identifier(identifier)
602                                    .revision(revision.as_str())
603                                    .files(selected_files)
604                                    .build()
605                            })
606                    }
607                    | Err(why) => Err(why),
608                },
609                | Err(why) => Err(why),
610            },
611            | (None, _) => Err(eyre!("Missing Hugging Face repository identifier")),
612            | (_, None) => Err(eyre!("Missing output directory")),
613        }
614    }
615    fn filter(&self, filter: Option<&str>, ignore: Option<&str>) -> ApiResult<Self> {
616        let filter_vec = filter.into_iter().map(String::from).collect::<Vec<_>>();
617        let ignore_vec = ignore.into_iter().map(String::from).collect::<Vec<_>>();
618        let filtered = match self.try_glob(filter, ignore) {
619            | Some(result) => Ok(result),
620            | None => FilterSet::filter(
621                self.files.clone(),
622                &filter_vec,
623                &ignore_vec,
624                |file: &HuggingFaceRepositoryFile| file.path.clone(),
625                |_| true,
626            )
627            .map(|files| Self::new(&self.identifier, &self.revision, files)),
628        };
629        filtered.and_then(|files| {
630            if filter.is_some() {
631                match files.is_empty() {
632                    | true => Err(eyre!("No model files matched --filter/--ignore")),
633                    | false => Ok(files),
634                }
635            } else {
636                let policy = FileSelectionPolicy {
637                    preferred_marker: "Q4_K_M",
638                    no_match_message: "No GGUF model files found; use a GGUF repository or provide --filter for another format",
639                };
640                files.select(&policy)
641            }
642        })
643    }
644    fn select(&self, policy: &FileSelectionPolicy<'_>) -> ApiResult<Self> {
645        let Self { identifier, revision, .. } = self;
646        let gguf_files = self
647            .iter()
648            .filter(|file| file.path.to_ascii_lowercase().ends_with(".gguf"))
649            .cloned()
650            .collect();
651        let files = Self::new(identifier, revision, gguf_files);
652        match files.len() {
653            | 0 => Err(eyre!(HuggingFaceError::NoGgufModelFiles)),
654            | 1 => Ok(files),
655            | _ => {
656                let preferred_files = files
657                    .iter()
658                    .filter(|file| file.path.to_ascii_uppercase().contains(policy.preferred_marker))
659                    .cloned()
660                    .collect();
661                let preferred = Self::new(identifier, revision, preferred_files);
662                let candidates = match preferred.is_empty() {
663                    | false => preferred,
664                    | true => {
665                        let repository_models = files.repository_model_files();
666                        match repository_models.is_empty() {
667                            | true => files.clone(),
668                            | false => repository_models,
669                        }
670                    }
671                };
672                match candidates.len() {
673                    | 1 => Ok(candidates),
674                    | _ => candidates.complete_shard_set().ok_or_else(|| {
675                        eyre!(
676                            "Multiple model file candidates found ({}); use --filter to choose one",
677                            files.candidate_report()
678                        )
679                    }),
680                }
681            }
682        }
683    }
684    fn should_use_fallback(&self, options: &Options) -> bool {
685        let Options {
686            identifier,
687            offline,
688            no_fallback,
689            filter,
690            ..
691        } = options;
692        let is_huggingface_repo_id = identifier
693            .as_deref()
694            .and_then(|value| value.split_once('/'))
695            .is_some_and(|(owner, name)| !owner.is_empty() && !name.is_empty() && !name.contains('/'));
696        let contains_gguf_files = self.iter().any(|file| file.path.to_ascii_lowercase().ends_with(".gguf"));
697        is_huggingface_repo_id && filter.is_none() && !(contains_gguf_files || *offline || *no_fallback)
698    }
699    fn try_glob(&self, filter: Option<&str>, ignore: Option<&str>) -> Option<Self> {
700        match (filter.map(regex_to_glob), ignore.map(regex_to_glob)) {
701            | (Some(None), _) | (_, Some(None)) => None,
702            | (filter_opt, ignore_opt) => {
703                let filter_glob = filter_opt.flatten();
704                let ignore_glob = ignore_opt.flatten();
705                let filtered = self
706                    .iter()
707                    .filter(|file| {
708                        let ignored = ignore_glob.as_ref().is_some_and(|pattern| glob_matches(&file.path, pattern));
709                        let kept = filter_glob.as_ref().is_none_or(|pattern| glob_matches(&file.path, pattern));
710                        !ignored && kept
711                    })
712                    .cloned()
713                    .collect();
714                Some(Self::new(&self.identifier, &self.revision, filtered))
715            }
716        }
717    }
718}
719impl ModelInfoExtension for ModelInfo {
720    fn has_gguf_files(&self) -> bool {
721        self.siblings
722            .as_ref()
723            .is_some_and(|siblings| siblings.iter().any(|file| file.rfilename.to_ascii_lowercase().ends_with(".gguf")))
724    }
725    fn is_fallback_for(&self, identifier: &str) -> bool {
726        self.id.eq_ignore_ascii_case(identifier) || self.is_declared_derivative_of(identifier) || self.is_declared_variant_of(identifier)
727    }
728    fn is_declared_derivative_of(&self, identifier: &str) -> bool {
729        let declares_base_model = self
730            .base_models
731            .as_ref()
732            .is_some_and(|models| models.iter().any(|value| value.eq_ignore_ascii_case(identifier)));
733        let declares_card_base_model = self
734            .card_data
735            .as_ref()
736            .and_then(|value| value.get("base_model"))
737            .is_some_and(|value| match value {
738                | Value::String(value) => value.eq_ignore_ascii_case(identifier),
739                | Value::Array(values) => values
740                    .iter()
741                    .filter_map(Value::as_str)
742                    .any(|value| value.eq_ignore_ascii_case(identifier)),
743                | _ => false,
744            });
745        let has_quantized_base_model_tag = self
746            .tags
747            .as_ref()
748            .is_some_and(|tags| tags.iter().any(|tag| is_quantized_base_model_tag(tag, identifier)));
749        declares_base_model || declares_card_base_model || has_quantized_base_model_tag
750    }
751    fn is_declared_variant_of(&self, identifier: &str) -> bool {
752        let matches = |value: &str| variant_matches(value, identifier);
753        let declares_base_model = self.base_models.as_ref().is_some_and(|models| models.iter().any(|value| matches(value)));
754        let declares_card_base_model = self
755            .card_data
756            .as_ref()
757            .and_then(|value| value.get("base_model"))
758            .is_some_and(|value| match value {
759                | Value::String(value) => matches(value),
760                | Value::Array(values) => values.iter().filter_map(Value::as_str).any(matches),
761                | _ => false,
762            });
763        let has_quantized_base_model_tag = self.tags.as_ref().is_some_and(|tags| {
764            tags.iter().any(|tag| {
765                let mut parts = tag.splitn(3, ':');
766                match (parts.next(), parts.next(), parts.next()) {
767                    | (Some(kind), Some(relation), Some(value)) => {
768                        kind.eq_ignore_ascii_case("base_model") && relation.eq_ignore_ascii_case("quantized") && matches(value)
769                    }
770                    | _ => false,
771                }
772            })
773        });
774        declares_base_model || declares_card_base_model || has_quantized_base_model_tag
775    }
776}
777impl ModelDetails {
778    /// Resolve this model to a GGUF fallback repository.
779    pub async fn resolve_fallback(self, search_options: &SearchOptions, offline: bool) -> ApiResult<RepositoryResolution<Self>> {
780        match search(search_options).await {
781            | Ok(candidates) => {
782                let options = Options::init()
783                    .identifier(&search_options.identifier)
784                    .offline(offline)
785                    .search_limit(search_options.limit)
786                    .minimum_download_count(search_options.minimum_download_count)
787                    .interactive(search_options.interactive)
788                    .quiet(true)
789                    .build();
790                candidates
791                    .select(&options)
792                    .map(|resolved| RepositoryResolution::new(&search_options.identifier, resolved, self))
793            }
794            | Err(why) => Err(why),
795        }
796    }
797}
798impl From<RepositoryResolution<ModelDetails>> for ModelDetails {
799    fn from(resolution: RepositoryResolution<ModelDetails>) -> Self {
800        let (requested, resolved, details) = resolution.into_parts();
801        let details = details.with_id(&resolved);
802        match requested == resolved {
803            | true => details,
804            | false => details.with_fallback(&requested),
805        }
806    }
807}
808impl<T> RepositoryResolution<T> {
809    /// Create a repository resolution from requested and resolved identifiers.
810    pub fn new(requested: impl Into<String>, resolved: impl Into<String>, value: T) -> Self {
811        Self {
812            requested: requested.into(),
813            resolved: resolved.into(),
814            value,
815        }
816    }
817    /// Create a direct repository resolution from one identifier.
818    pub fn direct(identifier: impl Into<String>, value: T) -> Self {
819        let requested = identifier.into();
820        let resolved = requested.clone();
821        Self::new(requested, resolved, value)
822    }
823    /// Return whether fallback discovery selected a different repository.
824    pub fn is_fallback(&self) -> bool {
825        self.requested != self.resolved
826    }
827    /// Return the originally requested repository identifier.
828    pub fn requested(&self) -> &str {
829        &self.requested
830    }
831    /// Return the repository identifier that supplied the resolved value.
832    pub fn resolved(&self) -> &str {
833        &self.resolved
834    }
835    /// Return the resolved value.
836    pub fn value(&self) -> &T {
837        &self.value
838    }
839    /// Transform the resolved value while preserving repository identity.
840    pub fn map<U>(self, transform: impl FnOnce(T) -> U) -> RepositoryResolution<U> {
841        let (requested, resolved, value) = self.into_parts();
842        RepositoryResolution::new(requested, resolved, transform(value))
843    }
844    /// Try to transform the resolved value while preserving repository identity.
845    pub fn try_map<U, E>(self, transform: impl FnOnce(T) -> Result<U, E>) -> Result<RepositoryResolution<U>, E> {
846        let (requested, resolved, value) = self.into_parts();
847        transform(value).map(|value| RepositoryResolution::new(requested, resolved, value))
848    }
849    /// Consume the resolution into its identifiers and value.
850    pub fn into_parts(self) -> (String, String, T) {
851        (self.requested, self.resolved, self.value)
852    }
853}
854impl From<&Source> for Weights {
855    fn from(source: &Source) -> Self {
856        match source {
857            | Source::Remote { identifier, .. } => Self(vec![Weight {
858                label: "Hugging Face".to_string(),
859                url: format!("https://{DEFAULT_HUGGINGFACE_DOMAIN}/{identifier}"),
860                is_open: None,
861                quantization: None,
862                size: None,
863            }]),
864            | Source::Local { path, .. } => Self(vec![Weight {
865                label: "Local".to_string(),
866                url: path.display().to_string(),
867                is_open: Some(true),
868                quantization: None,
869                size: None,
870            }]),
871            | Source::Unsupported(_) => Self::default(),
872        }
873    }
874}
875impl Weights {
876    /// Set the open-weight flag for the primary source.
877    pub fn open(mut self, is_open: Option<bool>) -> Self {
878        if let Some(weight) = self.0.first_mut() {
879            weight.is_open = is_open;
880        }
881        self
882    }
883    /// Construct weights from a source location with explicit open-weight control.
884    ///
885    /// For remote Hugging Face sources, `is_open` controls the open-weight flag.
886    /// For local sources, `is_open` is always `Some(true)`, regardless of the parameter.
887    pub fn from_source(source: &Source, is_open: Option<bool>) -> Self {
888        let weights = Self::from(source);
889        // Only override is_open for Remote sources; Local sources are always Some(true)
890        match source {
891            | Source::Remote { .. } => weights.open(is_open),
892            | _ => weights,
893        }
894    }
895    /// Resolve the first non-empty model weight URL into a downloadable source.
896    pub fn to_source(self, name: Option<String>) -> Option<Source> {
897        self.0.into_iter().find(|weight| !weight.url.trim().is_empty()).map(|weight| {
898            let location = Location::from(weight.url.as_str());
899            let is_repository = location.host().is_some_and(|host| host.eq_ignore_ascii_case(DEFAULT_HUGGINGFACE_DOMAIN))
900                && location
901                    .path()
902                    .is_some_and(|path| path.split('/').filter(|segment| !segment.is_empty()).count() == 2);
903            let source = match is_repository {
904                | true => Source::from(&Repository::HuggingFace { location }),
905                | false => Source::from(weight.url.as_str()),
906            };
907            source.with_name(name.unwrap_or(weight.label))
908        })
909    }
910}
911/// Build Hugging Face authorization headers using configured environment token values.
912pub fn auth_headers() -> HeaderMap {
913    Params::new()
914        .with_auth(first_env_var(&HUGGINGFACE_TOKEN_VARIABLE_NAMES).unwrap_or_default().as_str(), None)
915        .build()
916        .into_headers()
917}
918/// Fetch model metadata from Hugging Face API with base model and tag expansion
919pub async fn fetch_model_info(provider: &str, name: &str) -> ApiResult<ModelInfo> {
920    let client = HFClient::new().map_err(|why| {
921        eyre!(HuggingFaceError::ClientInitializationFailed {
922            reason: why.to_string().into()
923        })
924    });
925    match client {
926        | Ok(client) => client
927            .model(provider, name)
928            .info()
929            .expand(vec![
930                "baseModels".to_string(),
931                "cardData".to_string(),
932                "siblings".to_string(),
933                "tags".to_string(),
934            ])
935            .send()
936            .await
937            .map_err(|why| eyre!(why).wrap_err(format!("Failed to read Hugging Face metadata for '{provider}/{name}'"))),
938        | Err(e) => Err(e),
939    }
940}
941/// Return whether a non-empty Hugging Face token is configured
942pub fn has_auth_token() -> bool {
943    first_env_var(&HUGGINGFACE_TOKEN_VARIABLE_NAMES).is_some()
944}
945/// Return whether a Hugging Face error means a model is unavailable to the caller
946pub fn model_is_unavailable(error: &Report) -> bool {
947    error.downcast_ref::<HFError>().is_some_and(|source| match source {
948        | HFError::RepoNotFound { .. } | HFError::AuthRequired { .. } | HFError::Forbidden { .. } => true,
949        | HFError::Http { context } => matches!(context.status.as_u16(), 401 | 403 | 404),
950        | _ => false,
951    })
952}
953/// Validate and parse a Hugging Face model identifier into (owner, name)
954pub fn parse_identifier(identifier: &str) -> ApiResult<(&str, &str)> {
955    match identifier.split_once('/') {
956        | Some((owner, name)) if !owner.is_empty() && !name.is_empty() && !name.contains('/') => Ok((owner, name)),
957        | _ => Err(eyre!("Invalid Hugging Face model identifier — {identifier}")),
958    }
959}
960/// List files in a Hugging Face model repository at `revision`
961pub async fn repository_tree(identifier: &str, revision: &str) -> ApiResult<HuggingFaceRepositoryFiles> {
962    let template = "huggingface::api";
963    let action = "tree";
964    let options = Options::init().identifier(identifier).revision(revision).build();
965    let params = Params::new()
966        .with_auth(first_env_var(&HUGGINGFACE_TOKEN_VARIABLE_NAMES).unwrap_or_default().as_str(), None)
967        .with_template("identifier", options.identifier.as_deref())
968        .with_template("revision", Some(&options.revision))
969        .with_keyvalue("recursive", Some("1"))
970        .build();
971    match Endpoint::from_template(template) {
972        | Ok(endpoint) => match endpoint.invoke(action, Some(params)).await {
973            | Ok(ResponseContent::Json(content)) => HuggingFaceRepositoryFiles::parse(&content, &options),
974            | Ok(_) => Err(eyre!("Failed to list Hugging Face model files — response was not JSON")),
975            | Err(why) => Err(eyre!("Failed to list Hugging Face model files — {why}")),
976        },
977        | Err(why) => Err(eyre!("Failed to configure Hugging Face API — {why}")),
978    }
979}
980/// Find repositories matching the search options, sorted by downloads.
981pub async fn search(options: &SearchOptions) -> ApiResult<Candidates> {
982    let basename = ModelSelector::new(&options.identifier)
983        .map(|selector| selector.fallback_search_name())
984        .filter(|value| !value.is_empty());
985    match (basename, HFClient::new()) {
986        | (Some(basename), Ok(client)) => {
987            let response = client
988                .list_models()
989                .search(&basename)
990                .filter(&options.term)
991                .sort("downloads")
992                .full(true)
993                .card_data(true)
994                .limit(options.limit)
995                .send();
996            match response {
997                | Ok(stream) => match stream.try_collect::<Vec<ModelInfo>>().await {
998                    | Ok(models) => {
999                        let candidates = Candidates::fallback(models, options);
1000                        match candidates.is_empty() {
1001                            | true => Err(eyre!(HuggingFaceError::NoGgufQuantizationRepository {
1002                                identifier: options.identifier.clone().into()
1003                            })),
1004                            | false => Ok(candidates),
1005                        }
1006                    }
1007                    | Err(why) => Err(eyre!(HuggingFaceError::ModelSearchFailed {
1008                        reason: why.to_string().into()
1009                    })),
1010                },
1011                | Err(why) => Err(eyre!(HuggingFaceError::ModelSearchConfigurationFailed {
1012                    reason: why.to_string().into()
1013                })),
1014            }
1015        }
1016        | (None, _) => Err(eyre!(HuggingFaceError::InvalidBaseModelIdentifier {
1017            identifier: options.identifier.clone().into()
1018        })),
1019        | (_, Err(why)) => Err(eyre!(HuggingFaceError::ClientInitializationFailed {
1020            reason: why.to_string().into()
1021        })),
1022    }
1023}
1024fn extract_sha256(content: &str) -> Option<String> {
1025    content
1026        .split_whitespace()
1027        .find(|token| token.len() == 64 && token.chars().all(|character| character.is_ascii_hexdigit()))
1028        .map(|value| value.to_ascii_lowercase())
1029}
1030fn is_quantized_base_model_tag(tag: &str, identifier: &str) -> bool {
1031    let mut parts = tag.splitn(3, ':');
1032    match (parts.next(), parts.next(), parts.next()) {
1033        | (Some(kind), Some(relation), Some(value)) => {
1034            kind.eq_ignore_ascii_case("base_model") && relation.eq_ignore_ascii_case("quantized") && value.eq_ignore_ascii_case(identifier)
1035        }
1036        | _ => false,
1037    }
1038}
1039fn select_first(candidates: Candidates, _options: &Options) -> ApiResult<String> {
1040    candidates
1041        .into_iter()
1042        .next()
1043        .map(|candidate| candidate.to_string())
1044        .ok_or_else(|| eyre!("GGUF search returned no candidates"))
1045}
1046fn target_path_from_sidecar(path: &str) -> Option<String> {
1047    path.strip_suffix(".sha256")
1048        .or_else(|| path.strip_suffix(".sha256sum"))
1049        .map(ToString::to_string)
1050}
1051fn variant_matches(declared: &str, requested: &str) -> bool {
1052    match (declared.split_once('/'), requested.split_once('/')) {
1053        | (Some((declared_owner, declared_name)), Some((requested_owner, requested_name))) => {
1054            let same_owner = declared_owner.eq_ignore_ascii_case(requested_owner);
1055            let owner_is_meta = requested_owner.eq_ignore_ascii_case("meta") && declared_owner.eq_ignore_ascii_case("meta-llama");
1056            let requested_name = to_ascii_alphanumeric(strip_suffixes(FALLBACK_MODEL_SUFFIXES, requested_name));
1057            let name_matches = to_ascii_alphanumeric(declared_name).contains(&requested_name);
1058            (same_owner || owner_is_meta) && !requested_name.is_empty() && name_matches
1059        }
1060        | _ => false,
1061    }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::{
1067        extract_sha256, target_path_from_sidecar, Candidate, CandidateSelection, Candidates, Downloaded, HuggingFaceRepositoryFiles, ModelInfo,
1068        ModelInfoExtension, RepositoryResolution, SearchOptions, Value,
1069    };
1070    use crate::schema::agent::{ModelDetails, Weight, Weights};
1071    use serde_json::json;
1072
1073    fn model(value: Value) -> ModelInfo {
1074        serde_json::from_value(value).unwrap()
1075    }
1076
1077    #[test]
1078    fn test_has_gguf_files() {
1079        let gguf = model(json!({"id": "mozilla/test-llama", "siblings": [{"rfilename": "tiny-llama.gguf"}]}));
1080        assert!(gguf.has_gguf_files());
1081        let non_gguf = model(json!({"id": "openai/gpt-oss-20b", "siblings": [{"rfilename": "model.safetensors"}]}));
1082        assert!(!non_gguf.has_gguf_files());
1083    }
1084    #[test]
1085    fn test_is_declared_derivative() {
1086        let candidate = model(json!({"id": "community/quantized", "baseModels": ["OpenAI/GPT-OSS-2B"]}));
1087        assert!(candidate.is_declared_derivative_of("openai/gpt-oss-2b"));
1088        let candidate = model(json!({"id": "community/quantized", "tags": ["base_model:quantized:openai/gpt-oss-2b"]}));
1089        assert!(candidate.is_declared_derivative_of("openai/gpt-oss-2b"));
1090        let candidate = model(json!({
1091            "id": "community/quantized",
1092            "baseModels": ["other/model"],
1093            "tags": ["base_model:quantized:other/model"]
1094        }));
1095        assert!(!candidate.is_declared_derivative_of("openai/gpt-oss-2b"));
1096    }
1097    #[test]
1098    fn test_is_declared_variant_accepts_decorated_base_model_names() {
1099        let candidate = model(json!({
1100            "id": "unsloth/NVIDIA-Nemotron-3-Super-120B-A12B-GGUF",
1101            "cardData": {"base_model": ["nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16"]},
1102            "tags": ["base_model:quantized:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16"]
1103        }));
1104        assert!(candidate.is_declared_variant_of("nvidia/nemotron-3-super-120b-a12b"));
1105        assert!(!candidate.is_declared_variant_of("other/nemotron-3-super-120b-a12b"));
1106        assert!(!candidate.is_declared_variant_of("nvidia/different-model"));
1107    }
1108    #[test]
1109    fn test_is_declared_variant_accepts_meta_publisher_alias_and_catalog_suffixes() {
1110        let candidate = model(json!({
1111            "id": "unsloth/Llama-4-Maverick-17B-128E-Instruct-GGUF",
1112            "tags": ["base_model:quantized:meta-llama/Llama-4-Maverick-17B-128E-Instruct"]
1113        }));
1114        assert!(candidate.is_declared_variant_of("meta/llama-4-maverick-17b-128e-instruct"));
1115        assert!(candidate.is_declared_variant_of("meta/llama-4-maverick-17b-128e-instruct-fp8"));
1116        assert!(candidate.is_declared_variant_of("meta/llama-4-maverick-17b-128e-instruct-maas"));
1117        assert!(!candidate.is_declared_variant_of("other/llama-4-maverick-17b-128e-instruct"));
1118    }
1119    #[test]
1120    fn test_is_declared_variant_accepts_nvidia_version_separator_aliases() {
1121        let ultra = model(json!({
1122            "id": "bartowski/nvidia_Llama-3_1-Nemotron-Ultra-253B-v1-GGUF",
1123            "tags": ["base_model:quantized:nvidia/Llama-3_1-Nemotron-Ultra-253B-v1"]
1124        }));
1125        let super_model = model(json!({
1126            "id": "bartowski/nvidia_Llama-3_3-Nemotron-Super-49B-v1_5-GGUF",
1127            "tags": ["base_model:quantized:nvidia/Llama-3_3-Nemotron-Super-49B-v1_5"]
1128        }));
1129        assert!(ultra.is_declared_variant_of("nvidia/llama-3.1-nemotron-ultra-253b"));
1130        assert!(super_model.is_declared_variant_of("nvidia/llama-3.3-nemotron-super-49b-v1.5"));
1131        assert!(!super_model.is_declared_variant_of("nvidia/llama-nemotron-rerank-vl-1b-v2"));
1132    }
1133    #[test]
1134    fn test_gguf_candidate_includes_sorted_unique_quantizations() {
1135        let candidate = Candidate::from(model(json!({
1136            "id": "community/quantized",
1137            "downloads": 42,
1138            "likes": 7,
1139            "siblings": [
1140                {"rfilename": "model-Q5_K_M.gguf"},
1141                {"rfilename": "model-Q4_K_M.gguf"},
1142                {"rfilename": "model-Q4_K_M-00001-of-00002.gguf"}
1143            ]
1144        })));
1145        assert_eq!(candidate.downloads, 42);
1146        assert_eq!(candidate.likes, Some(7));
1147        assert_eq!(candidate.quantizations, vec!["Q4_K_M", "Q5_K_M"]);
1148        assert_eq!(candidate.to_string(), "community/quantized");
1149    }
1150    #[test]
1151    fn test_gguf_candidate_excludes_unrecognized_quantizations() {
1152        let candidate = Candidate::from(model(json!({
1153            "id": "community/unsupported",
1154            "siblings": [{"rfilename": "model-tq1_0.gguf"}]
1155        })));
1156        assert!(candidate.quantizations.is_empty());
1157    }
1158    #[test]
1159    fn test_fallback_candidates_apply_inclusive_minimum_download_count() {
1160        let candidate = |id: &str, downloads: Option<u64>| {
1161            model(json!({
1162                "id": id,
1163                "downloads": downloads,
1164                "tags": ["base_model:quantized:acme/base"],
1165                "siblings": [{"rfilename": "model-Q4_K_M.gguf"}]
1166            }))
1167        };
1168        let options = SearchOptions::init().identifier("acme/base").minimum_download_count(100).build();
1169        let candidates = Candidates::fallback(
1170            vec![
1171                candidate("acme/above-GGUF", Some(101)),
1172                candidate("acme/boundary-GGUF", Some(100)),
1173                candidate("acme/below-GGUF", Some(99)),
1174                candidate("acme/missing-GGUF", None),
1175            ],
1176            &options,
1177        );
1178        assert_eq!(
1179            candidates.iter().map(|candidate| candidate.id.as_str()).collect::<Vec<_>>(),
1180            vec!["acme/above-GGUF", "acme/boundary-GGUF"]
1181        );
1182    }
1183    #[test]
1184    fn test_weights_to_source_uses_hugging_face_repository_identifier() {
1185        let source = Weights(vec![Weight {
1186            label: "Hugging Face".to_string(),
1187            url: "https://huggingface.co/openai/gpt-oss-20b".to_string(),
1188            is_open: Some(true),
1189            quantization: None,
1190            size: None,
1191        }])
1192        .to_source(Some("GPT OSS 20B".to_string()))
1193        .unwrap();
1194        assert_eq!(source.identifier(), "openai/gpt-oss-20b");
1195        assert_eq!(source.name(), "GPT OSS 20B");
1196    }
1197    #[test]
1198    fn test_weights_to_source_keeps_direct_hugging_face_file_url() {
1199        let url = "https://huggingface.co/openai/gpt-oss-20b/resolve/main/model.gguf".to_string();
1200        let source = Weights(vec![Weight {
1201            label: "GGUF".to_string(),
1202            url: url.to_string(),
1203            is_open: Some(true),
1204            quantization: None,
1205            size: None,
1206        }])
1207        .to_source(None)
1208        .unwrap();
1209        assert_eq!(source.identifier(), url);
1210        assert_eq!(source.name(), "GGUF");
1211    }
1212    #[test]
1213    fn test_repository_tree_reports_api_error_message() {
1214        let options = super::Options::init().identifier("missing/model").revision("main").build();
1215        let error = HuggingFaceRepositoryFiles::parse(r#"{"error":"Repository not found"}"#, &options).unwrap_err();
1216        assert_eq!(
1217            error.to_string(),
1218            "Hugging Face API rejected repository 'missing/model' — Repository not found"
1219        );
1220    }
1221    #[test]
1222    fn test_repository_tree_parse_error_identifies_repository() {
1223        let options = super::Options::init().identifier("broken/model").revision("main").build();
1224        let error = HuggingFaceRepositoryFiles::parse(r#"{"unexpected":true}"#, &options).unwrap_err();
1225        assert!(error
1226            .to_string()
1227            .starts_with("Failed to parse Hugging Face repository file list for 'broken/model'"));
1228    }
1229    #[test]
1230    fn test_repository_resolution_identifies_direct_and_fallback_values() {
1231        let direct = RepositoryResolution::direct("acme/model", 1);
1232        assert!(!direct.is_fallback());
1233        assert_eq!(direct.requested(), "acme/model");
1234        assert_eq!(direct.resolved(), "acme/model");
1235        assert_eq!(*direct.value(), 1);
1236        let fallback = RepositoryResolution::new("acme/model", "community/model-GGUF", 2);
1237        assert!(fallback.is_fallback());
1238        assert_eq!(fallback.requested(), "acme/model");
1239        assert_eq!(fallback.resolved(), "community/model-GGUF");
1240        assert_eq!(fallback.into_parts(), ("acme/model".to_string(), "community/model-GGUF".to_string(), 2));
1241    }
1242    #[test]
1243    fn test_repository_resolution_transforms_values_without_losing_identity() {
1244        let mapped = RepositoryResolution::new("acme/model", "community/model-GGUF", 2).map(|value| value.to_string());
1245        assert_eq!(mapped.requested(), "acme/model");
1246        assert_eq!(mapped.resolved(), "community/model-GGUF");
1247        assert_eq!(mapped.value(), "2");
1248        let mapped = RepositoryResolution::direct("acme/model", 2).try_map(|value| if value > 0 { Ok(value * 2) } else { Err("invalid") });
1249        assert_eq!(
1250            mapped.map(RepositoryResolution::into_parts),
1251            Ok(("acme/model".to_string(), "acme/model".to_string(), 4))
1252        );
1253    }
1254    #[test]
1255    fn test_downloaded_into_resolution_uses_one_identifier() {
1256        let downloaded = Downloaded::init().identifier("acme/model").revision("main").files(Vec::new()).build();
1257        let resolution = downloaded.into_resolution("acme/model");
1258        assert!(!resolution.is_fallback());
1259        assert_eq!(resolution.requested(), "acme/model");
1260        assert_eq!(resolution.resolved(), "acme/model");
1261    }
1262    #[test]
1263    fn test_model_details_from_repository_resolution_preserves_fallback() {
1264        let resolution = RepositoryResolution::new("acme/model", "community/model-GGUF", ModelDetails::default());
1265        let details = ModelDetails::from(resolution);
1266        assert_eq!(details.id.as_deref(), Some("community/model-GGUF"));
1267        assert_eq!(details.fallback.as_deref(), Some("acme/model"));
1268    }
1269    #[test]
1270    fn test_target_path_from_sidecar() {
1271        assert_eq!(target_path_from_sidecar("model.gguf.sha256"), Some("model.gguf".to_string()));
1272        assert_eq!(
1273            target_path_from_sidecar("nested/model.gguf.sha256sum"),
1274            Some("nested/model.gguf".to_string())
1275        );
1276    }
1277    #[test]
1278    fn test_extract_sha256() {
1279        let digest = "ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD";
1280        assert_eq!(
1281            extract_sha256(format!("{digest}  model.gguf").as_str()).as_deref(),
1282            Some("abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"),
1283        );
1284        let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1285        assert_eq!(
1286            extract_sha256(format!("SHA256 ({digest}) = {digest} model.gguf").as_str()).as_deref(),
1287            Some(digest),
1288        );
1289        assert_eq!(extract_sha256("not-a-sha model.gguf"), None);
1290        assert_eq!(extract_sha256("0123456789abcdef"), None);
1291    }
1292}