Skip to main content

hf_fetch_model/
discover.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Model family discovery and search via the `HuggingFace` Hub API.
4//!
5//! Queries the HF Hub for popular models, extracts `model_type` metadata,
6//! compares against locally cached families, and fetches model card metadata.
7
8use std::collections::{BTreeMap, HashMap};
9use std::hash::BuildHasher;
10use std::sync::Arc;
11
12use serde::Deserialize;
13
14use crate::error::FetchError;
15
16/// A model found by searching the `HuggingFace` Hub.
17#[derive(Debug, Clone)]
18pub struct SearchResult {
19    /// The repository identifier (e.g., `"RWKV/RWKV7-Goose-World3-1.5B-HF"`).
20    pub model_id: String,
21    /// Total download count.
22    pub downloads: u64,
23    /// Library framework (e.g., `"transformers"`, `"peft"`, `"diffusers"`), if reported.
24    pub library_name: Option<String>,
25    /// Pipeline task tag (e.g., `"text-generation"`), if reported.
26    pub pipeline_tag: Option<String>,
27    /// Tags from the model's metadata (e.g., `["gguf", "conversational"]`).
28    pub tags: Vec<String>,
29}
30
31/// A model family discovered from the `HuggingFace` Hub.
32#[derive(Debug, Clone)]
33pub struct DiscoveredFamily {
34    /// The `model_type` identifier (e.g., `"gpt_neox"`, `"llama"`).
35    pub model_type: String,
36    /// The most-downloaded representative model for this family.
37    pub top_model: String,
38    /// Download count of the representative model.
39    pub downloads: u64,
40}
41
42/// JSON response structure for an individual model from the HF API.
43#[derive(Debug, Deserialize)]
44struct ApiModelEntry {
45    #[serde(rename = "modelId")]
46    model_id: String,
47    #[serde(default)]
48    downloads: u64,
49    #[serde(default)]
50    config: Option<ApiConfig>,
51    #[serde(default)]
52    library_name: Option<String>,
53    #[serde(default)]
54    pipeline_tag: Option<String>,
55    #[serde(default)]
56    tags: Vec<String>,
57}
58
59/// The `config` object embedded in a model API response.
60#[derive(Debug, Deserialize)]
61struct ApiConfig {
62    model_type: Option<String>,
63}
64
65/// Access control status of a model on the `HuggingFace` Hub.
66///
67/// Some models require users to accept license terms before downloading.
68/// The gating mode determines whether approval is automatic or manual.
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum GateStatus {
72    /// No gate — anyone can download without restrictions.
73    Open,
74    /// Automatic approval after the user accepts terms on the Hub.
75    Auto,
76    /// Manual approval by the model author after the user requests access.
77    Manual,
78}
79
80impl GateStatus {
81    /// Returns `true` if the model requires accepting terms before download.
82    #[must_use]
83    pub const fn is_gated(&self) -> bool {
84        matches!(self, Self::Auto | Self::Manual)
85    }
86}
87
88impl std::fmt::Display for GateStatus {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::Open => write!(f, "open"),
92            Self::Auto => write!(f, "auto"),
93            Self::Manual => write!(f, "manual"),
94        }
95    }
96}
97
98/// Metadata from a `HuggingFace` model card.
99///
100/// Extracted from the single-model API endpoint
101/// (`GET /api/models/{owner}/{model}`). All fields are optional
102/// because model cards may omit any of them.
103#[derive(Debug, Clone)]
104pub struct ModelCardMetadata {
105    /// SPDX license identifier (e.g., `"apache-2.0"`).
106    pub license: Option<String>,
107    /// Pipeline tag (e.g., `"text-generation"`).
108    pub pipeline_tag: Option<String>,
109    /// Tags associated with the model (e.g., `["pytorch", "safetensors"]`).
110    pub tags: Vec<String>,
111    /// Library name (e.g., `"transformers"`, `"vllm"`).
112    pub library_name: Option<String>,
113    /// Languages the model supports (e.g., `["en", "fr"]`).
114    pub languages: Vec<String>,
115    /// Access control status (open, auto-gated, or manually gated).
116    pub gated: GateStatus,
117}
118
119/// JSON response for a single model from `GET /api/models/{model_id}`.
120#[derive(Debug, Deserialize)]
121struct ApiModelDetail {
122    #[serde(default)]
123    pipeline_tag: Option<String>,
124    #[serde(default)]
125    tags: Vec<String>,
126    #[serde(default)]
127    library_name: Option<String>,
128    #[serde(default)]
129    gated: ApiGated,
130    #[serde(default, rename = "cardData")]
131    card_data: Option<ApiCardData>,
132}
133
134/// The `cardData` sub-object (parsed YAML front matter from the model README).
135#[derive(Debug, Deserialize)]
136struct ApiCardData {
137    #[serde(default)]
138    license: Option<String>,
139    #[serde(default)]
140    language: Option<ApiLanguage>,
141}
142
143/// Languages in `cardData` can be a single string or a list of strings.
144#[derive(Debug, Deserialize)]
145#[serde(untagged)]
146enum ApiLanguage {
147    Single(String),
148    Multiple(Vec<String>),
149}
150
151/// The `gated` field can be `false` (boolean) or a string like `"auto"` / `"manual"`.
152#[derive(Debug, Deserialize)]
153#[serde(untagged)]
154enum ApiGated {
155    Bool(bool),
156    Mode(String),
157}
158
159impl Default for ApiGated {
160    fn default() -> Self {
161        Self::Bool(false)
162    }
163}
164
165const PAGE_SIZE: usize = 100;
166const HF_API_BASE: &str = "https://huggingface.co/api/models";
167
168/// Queries the `HuggingFace` Hub API for top models by downloads
169/// and returns families not present in the local cache.
170///
171/// # Arguments
172///
173/// * `local_families` — Set of `model_type` values already cached locally.
174/// * `max_models` — Maximum number of models to scan (paginated in batches of 100).
175/// * `tag` — Optional tag filter (e.g., `"gguf"`, `"bitsandbytes"`). When set,
176///   only models carrying this tag contribute to family discovery.
177///
178/// # Errors
179///
180/// Returns [`FetchError::Http`] if any API request fails.
181pub async fn discover_new_families<S: BuildHasher>(
182    local_families: &std::collections::HashSet<String, S>,
183    max_models: usize,
184    tag: Option<&str>,
185) -> Result<Vec<DiscoveredFamily>, FetchError> {
186    let client = reqwest::Client::new();
187    let mut remote_families: BTreeMap<String, (String, u64)> = BTreeMap::new();
188    let mut offset: usize = 0;
189
190    while offset < max_models {
191        let page_limit = PAGE_SIZE.min(max_models.saturating_sub(offset));
192        let page_limit_str = page_limit.to_string();
193        let offset_str = offset.to_string();
194
195        // BORROW: explicit .as_str() instead of Deref coercion
196        let mut query_params: Vec<(&str, &str)> = vec![
197            ("config", "true"),
198            ("sort", "downloads"),
199            ("direction", "-1"),
200            ("limit", page_limit_str.as_str()),
201            ("offset", offset_str.as_str()),
202        ];
203        if let Some(t) = tag {
204            query_params.push(("filter", t));
205        }
206
207        let response = client
208            .get(HF_API_BASE)
209            .query(&query_params)
210            .send()
211            .await
212            .map_err(|e| FetchError::Http(e.to_string()))?;
213
214        if !response.status().is_success() {
215            return Err(FetchError::Http(format!(
216                "HF API returned status {}",
217                response.status()
218            )));
219        }
220
221        let models: Vec<ApiModelEntry> = response
222            .json()
223            .await
224            .map_err(|e| FetchError::Http(e.to_string()))?;
225
226        if models.is_empty() {
227            break;
228        }
229
230        for model in &models {
231            // Client-side tag filter: the HF API may ignore the `filter` query
232            // parameter when combined with other params, so verify the tag is
233            // actually present on each returned model.
234            if let Some(t) = tag {
235                if !model.tags.iter().any(|model_tag| {
236                    // BORROW: explicit .as_str() instead of Deref coercion
237                    model_tag.as_str().eq_ignore_ascii_case(t)
238                }) {
239                    continue;
240                }
241            }
242
243            // BORROW: explicit .as_ref() and .as_deref() for Option<String>
244            let model_type = model.config.as_ref().and_then(|c| c.model_type.as_deref());
245
246            if let Some(mt) = model_type {
247                remote_families
248                    .entry(mt.to_owned())
249                    .or_insert_with(|| (model.model_id.clone(), model.downloads));
250            }
251        }
252
253        offset = offset.saturating_add(models.len());
254    }
255
256    // Filter to families not already cached locally.
257    // BORROW: explicit .as_str() instead of Deref coercion
258    let discovered: Vec<DiscoveredFamily> = remote_families
259        .into_iter()
260        .filter(|(mt, _)| !local_families.contains(mt.as_str()))
261        .map(|(model_type, (top_model, downloads))| DiscoveredFamily {
262            model_type,
263            top_model,
264            downloads,
265        })
266        .collect();
267
268    Ok(discovered)
269}
270
271/// Normalizes common quantization synonyms in a search query so that
272/// variant spellings (e.g., `"8bit"`, `"8-bit"`, `"int8"`) produce
273/// consistent results.
274#[must_use]
275fn normalize_quantization_terms(query: &str) -> String {
276    /// Synonym groups: all variants map to the first (canonical) form.
277    const SYNONYMS: &[(&[&str], &str)] = &[
278        (&["8bit", "8-bit", "int8"], "8-bit"),
279        (&["4bit", "4-bit", "int4"], "4-bit"),
280        (&["fp8", "float8"], "fp8"),
281    ];
282
283    query
284        .split_whitespace()
285        .map(|token| {
286            // BORROW: explicit .to_lowercase() for case-insensitive comparison
287            let lower = token.to_lowercase();
288            for &(variants, canonical) in SYNONYMS {
289                // BORROW: explicit .as_str() instead of Deref coercion
290                if variants.contains(&lower.as_str()) {
291                    // BORROW: explicit .to_owned() for &str → owned String
292                    return (*canonical).to_owned();
293                }
294            }
295            // BORROW: explicit .to_owned() for &str → owned String
296            token.to_owned()
297        })
298        .collect::<Vec<_>>()
299        .join(" ")
300}
301
302/// Searches the `HuggingFace` Hub for models matching a query string.
303///
304/// Optionally filters by `library` framework (e.g., `"transformers"`, `"peft"`),
305/// `pipeline` task tag (e.g., `"text-generation"`), and/or `tag` (e.g., `"gguf"`).
306/// Library and pipeline filters are sent as query parameters; tag is sent via the
307/// `filter` parameter. All three are also applied client-side for correctness.
308///
309/// Common quantization synonyms (`"8bit"` / `"8-bit"` / `"int8"`,
310/// `"4bit"` / `"4-bit"` / `"int4"`, `"fp8"` / `"float8"`) are normalized
311/// before querying the API so that variant spellings return consistent results.
312///
313/// Results are sorted by download count (most popular first).
314///
315/// # Arguments
316///
317/// * `query` — Free-text search string (e.g., `"RWKV-7"`, `"llama 3"`).
318/// * `limit` — Maximum number of results to return.
319/// * `library` — Optional library filter (e.g., `"peft"`, `"transformers"`).
320/// * `pipeline` — Optional pipeline tag filter (e.g., `"text-generation"`).
321/// * `tag` — Optional tag filter (e.g., `"gguf"`, `"conversational"`).
322///
323/// # Errors
324///
325/// Returns [`FetchError::Http`] if the API request fails.
326pub async fn search_models(
327    query: &str,
328    limit: usize,
329    library: Option<&str>,
330    pipeline: Option<&str>,
331    tag: Option<&str>,
332) -> Result<Vec<SearchResult>, FetchError> {
333    let normalized = normalize_quantization_terms(query);
334    let client = reqwest::Client::new();
335
336    // BORROW: explicit .as_str() instead of Deref coercion
337    let mut query_params: Vec<(&str, &str)> = vec![
338        ("search", normalized.as_str()),
339        ("sort", "downloads"),
340        ("direction", "-1"),
341    ];
342    if let Some(lib) = library {
343        query_params.push(("library", lib));
344    }
345    if let Some(pipe) = pipeline {
346        query_params.push(("pipeline_tag", pipe));
347    }
348    if let Some(t) = tag {
349        query_params.push(("filter", t));
350    }
351
352    let response = client
353        .get(HF_API_BASE)
354        .query(&query_params)
355        .query(&[("limit", limit)])
356        .send()
357        .await
358        .map_err(|e| FetchError::Http(e.to_string()))?;
359
360    if !response.status().is_success() {
361        return Err(FetchError::Http(format!(
362            "HF API returned status {}",
363            response.status()
364        )));
365    }
366
367    let models: Vec<ApiModelEntry> = response
368        .json()
369        .await
370        .map_err(|e| FetchError::Http(e.to_string()))?;
371
372    // Client-side filtering: the HF search API may ignore library/pipeline_tag/filter
373    // query parameters when combined with the `search` parameter, so we filter
374    // the results ourselves to guarantee correctness.
375    let results = models
376        .into_iter()
377        .filter(|m| {
378            if let Some(lib) = library {
379                match m.library_name {
380                    // BORROW: explicit .as_str() instead of Deref coercion
381                    Some(ref name) if name.as_str().eq_ignore_ascii_case(lib) => {}
382                    _ => return false,
383                }
384            }
385            if let Some(pipe) = pipeline {
386                match m.pipeline_tag {
387                    // BORROW: explicit .as_str() instead of Deref coercion
388                    Some(ref t) if t.as_str().eq_ignore_ascii_case(pipe) => {}
389                    _ => return false,
390                }
391            }
392            if let Some(t) = tag {
393                if !m.tags.iter().any(|model_tag| {
394                    // BORROW: explicit .as_str() instead of Deref coercion
395                    model_tag.as_str().eq_ignore_ascii_case(t)
396                }) {
397                    return false;
398                }
399            }
400            true
401        })
402        .map(|m| SearchResult {
403            model_id: m.model_id,
404            downloads: m.downloads,
405            library_name: m.library_name,
406            pipeline_tag: m.pipeline_tag,
407            tags: m.tags,
408        })
409        .collect();
410
411    Ok(results)
412}
413
414/// Returns the total size in bytes of all files in the given repository's
415/// `main` revision, summed across `siblings[].size` from the
416/// `/api/models/{repo_id}?blobs=true` endpoint.
417///
418/// Used by `hf-fm search --show size` to enrich result rows with a total-repo
419/// size column. Failures are surfaced to the caller so individual repo
420/// lookups can be skipped (the search itself is not aborted on a single
421/// 404 / network blip).
422///
423/// # Arguments
424///
425/// * `repo_id` — The full model identifier (e.g., `"org/model"`).
426/// * `client` — Shared `reqwest::Client` (callers fan out N lookups concurrently).
427///
428/// # Errors
429///
430/// Returns [`FetchError::Http`] if the API request
431/// fails or returns a non-success status.
432/// Returns [`FetchError::RepoNotFound`] if
433/// the repository does not exist on the Hub.
434pub async fn fetch_repo_total_size(
435    repo_id: &str,
436    client: &reqwest::Client,
437) -> Result<u64, FetchError> {
438    let files = crate::repo::list_repo_files_with_metadata(repo_id, None, None, client).await?;
439    Ok(files.iter().filter_map(|f| f.size).sum())
440}
441
442/// Fans out [`fetch_repo_total_size`] across the given repository IDs through
443/// a bounded `tokio::sync::Semaphore` (8 permits) to stay friendly to the HF
444/// Hub on `--limit 100`-style invocations.
445///
446/// Per-repo failures (network errors, 404s, missing `size` fields) are
447/// silently dropped from the returned map; callers should render rows whose
448/// `repo_id` is absent from the map with a placeholder (`—`). The search
449/// itself is not aborted on a single failure.
450///
451/// # Arguments
452///
453/// * `repo_ids` — Owned list of model identifiers. Ownership is moved into
454///   the spawned tasks so each future is `'static`.
455#[must_use]
456pub async fn fetch_repo_sizes_concurrent(repo_ids: Vec<String>) -> HashMap<String, u64> {
457    let semaphore = Arc::new(tokio::sync::Semaphore::new(8));
458    let client = reqwest::Client::new();
459    let mut set: tokio::task::JoinSet<Option<(String, u64)>> = tokio::task::JoinSet::new();
460
461    for repo_id in repo_ids {
462        let sem = Arc::clone(&semaphore);
463        let client = client.clone();
464        set.spawn(async move {
465            let _permit = sem.acquire_owned().await.ok()?;
466            // EXPLICIT: per-repo failure intentionally swallowed — the caller
467            // renders the row with "—" rather than aborting the search.
468            match fetch_repo_total_size(&repo_id, &client).await {
469                Ok(bytes) => Some((repo_id, bytes)),
470                Err(_) => None,
471            }
472        });
473    }
474
475    let mut by_repo: HashMap<String, u64> = HashMap::new();
476    while let Some(joined) = set.join_next().await {
477        if let Ok(Some((repo_id, bytes))) = joined {
478            by_repo.insert(repo_id, bytes);
479        }
480    }
481
482    by_repo
483}
484
485/// Fans out [`fetch_model_card`] across the given repository IDs through a
486/// bounded `tokio::sync::Semaphore` (8 permits) and returns a map from
487/// `repo_id` to the model card's tag list.
488///
489/// Per-repo failures (network errors, 404s, missing models) are silently
490/// dropped from the returned map. Callers that want strict semantics should
491/// treat absence as "no tags known". Mirrors the same fan-out pattern used by
492/// [`fetch_repo_sizes_concurrent`] for `search --show size`.
493///
494/// # Arguments
495///
496/// * `repo_ids` — Owned list of model identifiers. Ownership is moved into
497///   the spawned tasks so each future is `'static`.
498#[must_use]
499pub async fn fetch_tags_concurrent(repo_ids: Vec<String>) -> HashMap<String, Vec<String>> {
500    let semaphore = Arc::new(tokio::sync::Semaphore::new(8));
501    let mut set: tokio::task::JoinSet<Option<(String, Vec<String>)>> = tokio::task::JoinSet::new();
502
503    for repo_id in repo_ids {
504        let sem = Arc::clone(&semaphore);
505        set.spawn(async move {
506            let _permit = sem.acquire_owned().await.ok()?;
507            // EXPLICIT: per-repo failure intentionally swallowed — missing
508            // tags mean the row simply doesn't match any --tag filter (the
509            // user's listing is not aborted on a single 404 / network blip).
510            match fetch_model_card(&repo_id).await {
511                Ok(card) => Some((repo_id, card.tags)),
512                Err(_) => None,
513            }
514        });
515    }
516
517    let mut by_repo: HashMap<String, Vec<String>> = HashMap::new();
518    while let Some(joined) = set.join_next().await {
519        if let Ok(Some((repo_id, tags))) = joined {
520            by_repo.insert(repo_id, tags);
521        }
522    }
523
524    by_repo
525}
526
527/// Fetches model card metadata for a specific model from the `HuggingFace` Hub.
528///
529/// Queries `GET https://huggingface.co/api/models/{model_id}` and extracts
530/// license, pipeline tag, tags, library name, and languages from the response.
531///
532/// # Arguments
533///
534/// * `model_id` — The full model identifier (e.g., `"mistralai/Ministral-3-3B-Instruct-2512"`).
535///
536/// # Errors
537///
538/// Returns [`FetchError::Http`] if the API request fails or the model is not found.
539pub async fn fetch_model_card(model_id: &str) -> Result<ModelCardMetadata, FetchError> {
540    let client = reqwest::Client::new();
541    let url = format!("{HF_API_BASE}/{model_id}");
542
543    let response = client
544        .get(url.as_str()) // BORROW: explicit .as_str()
545        .send()
546        .await
547        .map_err(|e| FetchError::Http(e.to_string()))?;
548
549    if !response.status().is_success() {
550        return Err(FetchError::Http(format!(
551            "HF API returned status {} for model {model_id}",
552            response.status()
553        )));
554    }
555
556    let detail: ApiModelDetail = response
557        .json()
558        .await
559        .map_err(|e| FetchError::Http(e.to_string()))?;
560
561    let (license, languages) = if let Some(card) = detail.card_data {
562        let langs = match card.language {
563            Some(ApiLanguage::Single(s)) => vec![s],
564            Some(ApiLanguage::Multiple(v)) => v,
565            None => Vec::new(),
566        };
567        (card.license, langs)
568    } else {
569        (None, Vec::new())
570    };
571
572    let gated = match detail.gated {
573        ApiGated::Bool(false) => GateStatus::Open,
574        ApiGated::Mode(ref mode) if mode.eq_ignore_ascii_case("manual") => GateStatus::Manual,
575        ApiGated::Bool(true) | ApiGated::Mode(_) => GateStatus::Auto,
576    };
577
578    Ok(ModelCardMetadata {
579        license,
580        pipeline_tag: detail.pipeline_tag,
581        tags: detail.tags,
582        library_name: detail.library_name,
583        languages,
584        gated,
585    })
586}
587
588/// Fetches the raw README text for a `HuggingFace` model repository.
589///
590/// Downloads `README.md` from the repository at the given revision.
591/// Returns `Ok(None)` if the file does not exist (HTTP 404).
592///
593/// # Arguments
594///
595/// * `model_id` — The full model identifier (e.g., `"mistralai/Ministral-3-3B-Instruct-2512"`).
596/// * `revision` — Git revision to fetch (defaults to `"main"` when `None`).
597/// * `token` — Optional authentication token.
598///
599/// # Errors
600///
601/// Returns [`FetchError::Http`] if the request fails (other than 404).
602pub async fn fetch_readme(
603    model_id: &str,
604    revision: Option<&str>,
605    token: Option<&str>,
606) -> Result<Option<String>, FetchError> {
607    let rev = revision.unwrap_or("main");
608    let url = crate::chunked::build_download_url(model_id, rev, "README.md");
609    let client = crate::chunked::build_client(token)?;
610
611    let response = client
612        .get(url.as_str()) // BORROW: explicit .as_str() instead of Deref coercion
613        .send()
614        .await
615        .map_err(|e| FetchError::Http(format!("failed to fetch README for {model_id}: {e}")))?;
616
617    if response.status() == reqwest::StatusCode::NOT_FOUND {
618        return Ok(None);
619    }
620
621    if !response.status().is_success() {
622        return Err(FetchError::Http(format!(
623            "README request for {model_id} returned status {}",
624            response.status()
625        )));
626    }
627
628    let text = response
629        .text()
630        .await
631        .map_err(|e| FetchError::Http(format!("failed to read README for {model_id}: {e}")))?;
632
633    Ok(Some(text))
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn normalize_8bit_variants() {
642        assert_eq!(normalize_quantization_terms("AWQ 8bit"), "AWQ 8-bit");
643        assert_eq!(normalize_quantization_terms("AWQ 8-bit"), "AWQ 8-bit");
644        assert_eq!(normalize_quantization_terms("AWQ int8"), "AWQ 8-bit");
645        assert_eq!(normalize_quantization_terms("AWQ INT8"), "AWQ 8-bit");
646    }
647
648    #[test]
649    fn normalize_4bit_variants() {
650        assert_eq!(normalize_quantization_terms("GPTQ 4bit"), "GPTQ 4-bit");
651        assert_eq!(normalize_quantization_terms("GPTQ INT4"), "GPTQ 4-bit");
652        assert_eq!(normalize_quantization_terms("GPTQ 4-bit"), "GPTQ 4-bit");
653    }
654
655    #[test]
656    fn normalize_fp8_variants() {
657        assert_eq!(normalize_quantization_terms("FP8"), "fp8");
658        assert_eq!(normalize_quantization_terms("float8"), "fp8");
659        assert_eq!(normalize_quantization_terms("fp8"), "fp8");
660    }
661
662    #[test]
663    fn normalize_passthrough() {
664        assert_eq!(normalize_quantization_terms("llama 3"), "llama 3");
665        assert_eq!(normalize_quantization_terms("RWKV-7"), "RWKV-7");
666    }
667}