1use 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
31pub type Candidates = Vec<Candidate>;
33pub type CandidateSelector = fn(Candidates, &Options) -> ApiResult<String>;
35pub trait CandidateSelection {
37 fn fallback(models: Vec<ModelInfo>, options: &SearchOptions) -> Self;
39 fn select(self, options: &Options) -> ApiResult<String>;
41 fn select_interactively(self, options: &Options) -> ApiResult<String>;
45}
46#[async_trait]
48pub trait HuggingFaceRepository {
49 async fn checksums(&self, identifier: &str, revision: &str) -> ApiResult<HashMap<String, String>>;
51 async fn download(&self, options: &Options) -> ApiResult<Downloaded>;
53 fn filter(&self, filter: Option<&str>, ignore: Option<&str>) -> ApiResult<Self>
55 where
56 Self: Sized;
57 fn select(&self, policy: &FileSelectionPolicy<'_>) -> ApiResult<Self>
59 where
60 Self: Sized;
61 fn should_use_fallback(&self, options: &Options) -> bool;
63 fn try_glob(&self, filter: Option<&str>, ignore: Option<&str>) -> Option<Self>
65 where
66 Self: Sized;
67}
68pub trait ModelInfoExtension {
70 fn has_gguf_files(&self) -> bool;
72 fn is_fallback_for(&self, identifier: &str) -> bool;
74 fn is_declared_derivative_of(&self, identifier: &str) -> bool;
79 fn is_declared_variant_of(&self, identifier: &str) -> bool;
84}
85#[derive(Clone, Debug, Eq, PartialEq)]
87pub enum HuggingFaceError {
88 NoGgufModelFiles,
90 NoGgufQuantizationRepository {
92 identifier: Box<str>,
94 },
95 InvalidBaseModelIdentifier {
97 identifier: Box<str>,
99 },
100 ClientInitializationFailed {
102 reason: Box<str>,
104 },
105 ModelSearchConfigurationFailed {
107 reason: Box<str>,
109 },
110 ModelSearchFailed {
112 reason: Box<str>,
114 },
115}
116#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
118pub struct Candidate {
119 pub id: String,
121 pub downloads: u64,
123 pub likes: Option<u64>,
125 pub quantizations: Vec<String>,
127}
128#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct RepositoryResolution<T> {
131 requested: String,
132 resolved: String,
133 value: T,
134}
135#[derive(Builder, Clone, Debug, Eq, PartialEq)]
137#[builder(start_fn = init, on(String, into))]
138pub struct Downloaded {
139 pub identifier: String,
141 pub revision: String,
143 pub files: Vec<String>,
145}
146#[derive(Clone, Debug)]
148pub struct FileSelectionPolicy<'a> {
149 pub preferred_marker: &'a str,
151 pub no_match_message: &'a str,
153}
154#[derive(Builder, Clone, Debug)]
156#[builder(start_fn = init, on(String, into))]
157pub struct SearchOptions {
158 pub identifier: String,
160 #[builder(default = DEFAULT_HUGGINGFACE_SEARCH_TERM.to_string())]
162 pub term: String,
163 #[builder(default = DEFAULT_HUGGINGFACE_SEARCH_LIMIT)]
165 pub limit: usize,
166 #[builder(default = DEFAULT_HUGGINGFACE_MINIMUM_DOWNLOAD_COUNT)]
168 pub minimum_download_count: u64,
169 #[builder(default)]
171 pub interactive: bool,
172}
173#[derive(Builder, Clone, Debug)]
175#[builder(start_fn = init, on(String, into))]
176pub struct Options {
177 pub token: Option<String>,
179 #[builder(default = String::from("huggingface.co"))]
181 pub domain: String,
182 pub identifier: Option<String>,
184 #[builder(default = String::from(DEFAULT_HUGGINGFACE_MODEL_REVISION))]
186 pub revision: String,
187 pub path: Option<String>,
189 pub filter: Option<String>,
191 pub ignore: Option<String>,
193 #[builder(default)]
195 pub quiet: bool,
196 #[builder(default)]
198 pub offline: bool,
199 #[builder(default)]
201 pub no_fallback: bool,
202 #[builder(default = DEFAULT_HUGGINGFACE_SEARCH_LIMIT)]
204 pub search_limit: usize,
205 #[builder(default = DEFAULT_HUGGINGFACE_MINIMUM_DOWNLOAD_COUNT)]
207 pub minimum_download_count: u64,
208 #[builder(default)]
210 pub interactive: bool,
211 #[builder(default = select_first)]
213 pub selector: CandidateSelector,
214 #[builder(default = vec![])]
216 pub custom_params: Vec<Param>,
217 #[builder(default)]
219 pub skip_verify_checksum: bool,
220 pub output: Option<String>,
222}
223#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct HuggingFaceRepositoryFile {
226 pub path: String,
228 pub size: Option<u64>,
230}
231#[derive(Clone, Debug, PartialEq, Eq)]
233pub struct HuggingFaceRepositoryFiles {
234 pub files: Vec<HuggingFaceRepositoryFile>,
236 pub identifier: String,
238 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 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 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 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 pub fn into_resolution(self, identifier: impl Into<String>) -> RepositoryResolution<Self> {
490 RepositoryResolution::direct(identifier, self)
491 }
492 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 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 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 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 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 pub fn is_fallback(&self) -> bool {
825 self.requested != self.resolved
826 }
827 pub fn requested(&self) -> &str {
829 &self.requested
830 }
831 pub fn resolved(&self) -> &str {
833 &self.resolved
834 }
835 pub fn value(&self) -> &T {
837 &self.value
838 }
839 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 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 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 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 pub fn from_source(source: &Source, is_open: Option<bool>) -> Self {
888 let weights = Self::from(source);
889 match source {
891 | Source::Remote { .. } => weights.open(is_open),
892 | _ => weights,
893 }
894 }
895 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}
911pub 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}
918pub 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}
941pub fn has_auth_token() -> bool {
943 first_env_var(&HUGGINGFACE_TOKEN_VARIABLE_NAMES).is_some()
944}
945pub 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}
953pub 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}
960pub 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}
980pub 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}