1use std::collections::{BTreeMap, HashMap};
9use std::hash::BuildHasher;
10use std::sync::Arc;
11
12use serde::Deserialize;
13
14use crate::error::FetchError;
15
16#[derive(Debug, Clone)]
18pub struct SearchResult {
19 pub model_id: String,
21 pub downloads: u64,
23 pub library_name: Option<String>,
25 pub pipeline_tag: Option<String>,
27 pub tags: Vec<String>,
29}
30
31#[derive(Debug, Clone)]
33pub struct DiscoveredFamily {
34 pub model_type: String,
36 pub top_model: String,
38 pub downloads: u64,
40}
41
42#[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#[derive(Debug, Deserialize)]
61struct ApiConfig {
62 model_type: Option<String>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum GateStatus {
72 Open,
74 Auto,
76 Manual,
78}
79
80impl GateStatus {
81 #[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#[derive(Debug, Clone)]
104pub struct ModelCardMetadata {
105 pub license: Option<String>,
107 pub pipeline_tag: Option<String>,
109 pub tags: Vec<String>,
111 pub library_name: Option<String>,
113 pub languages: Vec<String>,
115 pub gated: GateStatus,
117}
118
119#[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#[derive(Debug, Deserialize)]
136struct ApiCardData {
137 #[serde(default)]
138 license: Option<String>,
139 #[serde(default)]
140 language: Option<ApiLanguage>,
141}
142
143#[derive(Debug, Deserialize)]
145#[serde(untagged)]
146enum ApiLanguage {
147 Single(String),
148 Multiple(Vec<String>),
149}
150
151#[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
168pub 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 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 if let Some(t) = tag {
235 if !model.tags.iter().any(|model_tag| {
236 model_tag.as_str().eq_ignore_ascii_case(t)
238 }) {
239 continue;
240 }
241 }
242
243 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 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#[must_use]
275fn normalize_quantization_terms(query: &str) -> String {
276 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 let lower = token.to_lowercase();
288 for &(variants, canonical) in SYNONYMS {
289 if variants.contains(&lower.as_str()) {
291 return (*canonical).to_owned();
293 }
294 }
295 token.to_owned()
297 })
298 .collect::<Vec<_>>()
299 .join(" ")
300}
301
302pub 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 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 let results = models
376 .into_iter()
377 .filter(|m| {
378 if let Some(lib) = library {
379 match m.library_name {
380 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 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 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
414pub 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#[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 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#[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 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
527pub 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()) .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
588pub 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()) .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}