use std::collections::{BTreeMap, HashMap};
use std::future::Future;
use std::hash::BuildHasher;
use std::sync::Arc;
use serde::Deserialize;
use crate::error::FetchError;
#[derive(Debug, Clone)]
pub struct SearchResult {
pub model_id: String,
pub downloads: u64,
pub library_name: Option<String>,
pub pipeline_tag: Option<String>,
pub tags: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct DiscoveredFamily {
pub model_type: String,
pub top_model: String,
pub downloads: u64,
}
#[derive(Debug, Deserialize)]
struct ApiModelEntry {
#[serde(rename = "modelId")]
model_id: String,
#[serde(default)]
downloads: u64,
#[serde(default)]
config: Option<ApiConfig>,
#[serde(default)]
library_name: Option<String>,
#[serde(default)]
pipeline_tag: Option<String>,
#[serde(default)]
tags: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ApiConfig {
model_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum GateStatus {
Open,
Auto,
Manual,
}
impl GateStatus {
#[must_use]
pub const fn is_gated(&self) -> bool {
matches!(self, Self::Auto | Self::Manual)
}
}
impl std::fmt::Display for GateStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Open => write!(f, "open"),
Self::Auto => write!(f, "auto"),
Self::Manual => write!(f, "manual"),
}
}
}
#[derive(Debug, Clone)]
pub struct ModelCardMetadata {
pub license: Option<String>,
pub pipeline_tag: Option<String>,
pub tags: Vec<String>,
pub library_name: Option<String>,
pub languages: Vec<String>,
pub gated: GateStatus,
}
#[derive(Debug, Deserialize)]
struct ApiModelDetail {
#[serde(default)]
pipeline_tag: Option<String>,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
library_name: Option<String>,
#[serde(default)]
gated: ApiGated,
#[serde(default, rename = "cardData")]
card_data: Option<ApiCardData>,
}
#[derive(Debug, Deserialize)]
struct ApiCardData {
#[serde(default)]
license: Option<String>,
#[serde(default)]
language: Option<ApiLanguage>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ApiLanguage {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ApiGated {
Bool(bool),
Mode(String),
}
impl Default for ApiGated {
fn default() -> Self {
Self::Bool(false)
}
}
const PAGE_SIZE: usize = 100;
const HF_API_BASE: &str = "https://huggingface.co/api/models";
pub async fn discover_new_families<S: BuildHasher>(
local_families: &std::collections::HashSet<String, S>,
max_models: usize,
tag: Option<&str>,
) -> Result<Vec<DiscoveredFamily>, FetchError> {
let client = reqwest::Client::new();
let mut remote_families: BTreeMap<String, (String, u64)> = BTreeMap::new();
let mut offset: usize = 0;
while offset < max_models {
let page_limit = PAGE_SIZE.min(max_models.saturating_sub(offset));
let page_limit_str = page_limit.to_string();
let offset_str = offset.to_string();
let mut query_params: Vec<(&str, &str)> = vec![
("config", "true"),
("sort", "downloads"),
("direction", "-1"),
("limit", page_limit_str.as_str()),
("offset", offset_str.as_str()),
];
if let Some(t) = tag {
query_params.push(("filter", t));
}
let response = client
.get(HF_API_BASE)
.query(&query_params)
.send()
.await
.map_err(|e| FetchError::Http(e.to_string()))?;
if !response.status().is_success() {
return Err(FetchError::Http(format!(
"HF API returned status {}",
response.status()
)));
}
let models: Vec<ApiModelEntry> = response
.json()
.await
.map_err(|e| FetchError::Http(e.to_string()))?;
if models.is_empty() {
break;
}
for model in &models {
if let Some(t) = tag
&& !model.tags.iter().any(|model_tag| {
model_tag.as_str().eq_ignore_ascii_case(t)
})
{
continue;
}
let model_type = model.config.as_ref().and_then(|c| c.model_type.as_deref());
if let Some(mt) = model_type {
remote_families
.entry(mt.to_owned())
.or_insert_with(|| (model.model_id.clone(), model.downloads));
}
}
offset = offset.saturating_add(models.len());
}
let discovered: Vec<DiscoveredFamily> = remote_families
.into_iter()
.filter(|(mt, _)| !local_families.contains(mt.as_str()))
.map(|(model_type, (top_model, downloads))| DiscoveredFamily {
model_type,
top_model,
downloads,
})
.collect();
Ok(discovered)
}
#[must_use]
fn normalize_quantization_terms(query: &str) -> String {
const SYNONYMS: &[(&[&str], &str)] = &[
(&["8bit", "8-bit", "int8"], "8-bit"),
(&["4bit", "4-bit", "int4"], "4-bit"),
(&["fp8", "float8"], "fp8"),
];
query
.split_whitespace()
.map(|token| {
let lower = token.to_lowercase();
for &(variants, canonical) in SYNONYMS {
if variants.contains(&lower.as_str()) {
return (*canonical).to_owned();
}
}
token.to_owned()
})
.collect::<Vec<_>>()
.join(" ")
}
pub async fn search_models(
query: &str,
limit: usize,
library: Option<&str>,
pipeline: Option<&str>,
tag: Option<&str>,
token: Option<&str>,
) -> Result<Vec<SearchResult>, FetchError> {
let normalized = normalize_quantization_terms(query);
let client = crate::chunked::build_client(token)?;
let mut query_params: Vec<(&str, &str)> = vec![
("search", normalized.as_str()),
("sort", "downloads"),
("direction", "-1"),
];
if let Some(lib) = library {
query_params.push(("library", lib));
}
if let Some(pipe) = pipeline {
query_params.push(("pipeline_tag", pipe));
}
if let Some(t) = tag {
query_params.push(("filter", t));
}
let response = client
.get(HF_API_BASE)
.query(&query_params)
.query(&[("limit", limit)])
.send()
.await
.map_err(|e| FetchError::Http(e.to_string()))?;
if !response.status().is_success() {
return Err(FetchError::Http(format!(
"HF API returned status {}",
response.status()
)));
}
let models: Vec<ApiModelEntry> = response
.json()
.await
.map_err(|e| FetchError::Http(e.to_string()))?;
let results = models
.into_iter()
.filter(|m| {
if let Some(lib) = library {
match m.library_name {
Some(ref name) if name.as_str().eq_ignore_ascii_case(lib) => {}
_ => return false,
}
}
if let Some(pipe) = pipeline {
match m.pipeline_tag {
Some(ref t) if t.as_str().eq_ignore_ascii_case(pipe) => {}
_ => return false,
}
}
if let Some(t) = tag
&& !m.tags.iter().any(|model_tag| {
model_tag.as_str().eq_ignore_ascii_case(t)
})
{
return false;
}
true
})
.map(|m| SearchResult {
model_id: m.model_id,
downloads: m.downloads,
library_name: m.library_name,
pipeline_tag: m.pipeline_tag,
tags: m.tags,
})
.collect();
Ok(results)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum GgufFileSetKind {
Sharded {
first_shard: String,
},
QuantAlternatives,
NotApplicable,
}
#[must_use]
pub fn is_gguf_filename(filename: &str) -> bool {
filename.to_ascii_lowercase().ends_with(".gguf")
}
fn parse_gguf_split_name(filename: &str) -> Option<(&str, u32, u32)> {
if !is_gguf_filename(filename) {
return None;
}
let stem = filename.get(..filename.len().checked_sub(5)?)?;
let (before, total_str) = stem.split_once("-of-")?;
let (prefix, index_str) = before.rsplit_once('-')?;
if index_str.is_empty()
|| total_str.is_empty()
|| index_str.len() != total_str.len()
|| !index_str.bytes().all(|b| b.is_ascii_digit())
|| !total_str.bytes().all(|b| b.is_ascii_digit())
{
return None;
}
let index: u32 = index_str.parse().ok()?;
let total: u32 = total_str.parse().ok()?;
Some((prefix, index, total))
}
#[must_use]
pub fn classify_gguf_files(filenames: &[&str]) -> GgufFileSetKind {
let gguf: Vec<&str> = filenames
.iter()
.copied()
.filter(|f| is_gguf_filename(f))
.collect();
if gguf.len() <= 1 {
return GgufFileSetKind::NotApplicable;
}
let Some(parsed): Option<Vec<(&str, u32, u32)>> =
gguf.iter().map(|f| parse_gguf_split_name(f)).collect()
else {
return GgufFileSetKind::QuantAlternatives;
};
let Some(&(first_prefix, _, first_total)) = parsed.first() else {
return GgufFileSetKind::QuantAlternatives;
};
let same_group = parsed
.iter()
.all(|&(prefix, _, total)| prefix == first_prefix && total == first_total);
if !same_group {
return GgufFileSetKind::QuantAlternatives;
}
#[allow(clippy::as_conversions)]
let total_usize = first_total as usize;
if gguf.len() != total_usize {
return GgufFileSetKind::QuantAlternatives;
}
let mut indices: Vec<u32> = parsed.iter().map(|&(_, index, _)| index).collect();
indices.sort_unstable();
let complete = indices.iter().enumerate().all(|(i, &index)| {
#[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
let expected = i as u32 + 1;
index == expected
});
if !complete {
return GgufFileSetKind::QuantAlternatives;
}
let Some((&first_shard, _)) = gguf
.iter()
.zip(parsed.iter())
.find(|&(_, &(_, index, _))| index == 1)
else {
return GgufFileSetKind::QuantAlternatives;
};
GgufFileSetKind::Sharded {
first_shard: first_shard.to_owned(),
}
}
#[must_use]
pub fn gguf_size_range<'a, I>(filenames_with_size: I) -> Option<(u64, u64)>
where
I: IntoIterator<Item = (&'a str, Option<u64>)>,
{
let pairs: Vec<(&str, Option<u64>)> = filenames_with_size.into_iter().collect();
let filenames: Vec<&str> = pairs.iter().map(|&(name, _)| name).collect();
if !matches!(
classify_gguf_files(&filenames),
GgufFileSetKind::QuantAlternatives
) {
return None;
}
let sizes: Vec<u64> = pairs
.iter()
.filter(|&&(name, _)| is_gguf_filename(name))
.filter_map(|&(_, size)| size)
.collect();
let min = sizes.iter().copied().min()?;
let max = sizes.iter().copied().max()?;
Some((min, max))
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct RepoSizeSummary {
pub total: u64,
pub quant_alternatives: bool,
pub size_min: Option<u64>,
pub size_max: Option<u64>,
}
pub async fn fetch_repo_size_summary(
repo_id: &str,
client: &reqwest::Client,
) -> Result<RepoSizeSummary, FetchError> {
let files = crate::repo::list_repo_files_with_metadata(repo_id, None, None, client).await?;
let total: u64 = files.iter().filter_map(|f| f.size).sum();
let sized: Vec<(&str, Option<u64>)> = files
.iter()
.map(|f| (f.filename.as_str(), f.size))
.collect();
let range = gguf_size_range(sized);
Ok(RepoSizeSummary {
total,
quant_alternatives: range.is_some(),
size_min: range.map(|(min, _)| min),
size_max: range.map(|(_, max)| max),
})
}
pub async fn fetch_repo_total_size(
repo_id: &str,
client: &reqwest::Client,
) -> Result<u64, FetchError> {
let files = crate::repo::list_repo_files_with_metadata(repo_id, None, None, client).await?;
Ok(files.iter().filter_map(|f| f.size).sum())
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum QuantVerification {
Verified,
Unverified,
CheckFailed(String),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct QuantCandidate {
pub repo_id: String,
pub verification: QuantVerification,
pub files: Vec<crate::repo::RepoFile>,
}
impl QuantCandidate {
#[must_use]
pub fn new(
repo_id: String,
verification: QuantVerification,
files: Vec<crate::repo::RepoFile>,
) -> Self {
Self {
repo_id,
verification,
files,
}
}
}
fn gguf_source_backlinks(metadata: &HashMap<String, String>) -> Vec<&str> {
metadata
.iter()
.filter(|(key, _)| {
key.as_str() == "general.source.url"
|| key.as_str() == "general.source.huggingface.repository"
|| (key.starts_with("general.base_model.") && key.ends_with(".repo_url"))
})
.map(|(_, value)| value.as_str())
.collect()
}
fn pick_backlink_representative(
gguf_files: &[crate::repo::RepoFile],
) -> Option<&crate::repo::RepoFile> {
let filenames: Vec<&str> = gguf_files.iter().map(|f| f.filename.as_str()).collect();
if let GgufFileSetKind::Sharded { first_shard } = classify_gguf_files(&filenames)
&& let Some(file) = gguf_files.iter().find(|f| f.filename == first_shard)
{
return Some(file);
}
gguf_files.iter().min_by_key(|f| f.size.unwrap_or(u64::MAX))
}
async fn build_quant_candidate(
candidate_repo_id: String,
base_repo_id: &str,
token: Option<&str>,
client: &reqwest::Client,
) -> Option<QuantCandidate> {
let files = crate::repo::list_repo_files_with_metadata(&candidate_repo_id, token, None, client)
.await
.ok()?;
let gguf_files: Vec<_> = files
.iter()
.filter(|f| is_gguf_filename(&f.filename))
.cloned()
.collect();
let Some(representative) = pick_backlink_representative(&gguf_files) else {
return Some(QuantCandidate {
repo_id: candidate_repo_id,
verification: QuantVerification::Unverified,
files,
});
};
match crate::inspect::inspect_gguf(&candidate_repo_id, &representative.filename, token, None)
.await
{
Err(e) => Some(QuantCandidate {
repo_id: candidate_repo_id,
verification: QuantVerification::CheckFailed(e.to_string()),
files,
}),
Ok((info, _source, _stats)) => {
let backlinks = info
.metadata
.as_ref()
.map(|m| gguf_source_backlinks(m))
.unwrap_or_default();
if backlinks.is_empty() {
return Some(QuantCandidate {
repo_id: candidate_repo_id,
verification: QuantVerification::Unverified,
files,
});
}
let base_lower = base_repo_id.to_lowercase();
let matched = backlinks
.iter()
.any(|b| b.to_lowercase().contains(&base_lower));
if matched {
Some(QuantCandidate {
repo_id: candidate_repo_id,
verification: QuantVerification::Verified,
files,
})
} else {
None }
}
}
}
pub async fn discover_quant_siblings(
base_repo_id: &str,
token: Option<&str>,
client: &reqwest::Client,
) -> Result<Vec<QuantCandidate>, FetchError> {
let short_name = base_repo_id.rsplit('/').next().unwrap_or(base_repo_id);
let results = search_models(short_name, 50, None, None, None, token).await?;
let short_name_lower = short_name.to_lowercase();
let semaphore = Arc::new(tokio::sync::Semaphore::new(8));
let mut set: tokio::task::JoinSet<Option<QuantCandidate>> = tokio::task::JoinSet::new();
for result in results {
if result.model_id.eq_ignore_ascii_case(base_repo_id) {
continue; }
if !result.model_id.to_lowercase().contains(&short_name_lower) {
continue;
}
let limiter = Arc::clone(&semaphore);
let client = client.clone();
let base_repo_owned = base_repo_id.to_owned();
let token_owned = token.map(str::to_owned);
set.spawn(async move {
let _permit = limiter.acquire_owned().await.ok()?;
build_quant_candidate(
result.model_id,
&base_repo_owned,
token_owned.as_deref(),
&client,
)
.await
});
}
let mut candidates: Vec<QuantCandidate> = Vec::new();
while let Some(joined) = set.join_next().await {
if let Ok(Some(candidate)) = joined {
candidates.push(candidate);
}
}
candidates.sort_by(|a, b| a.repo_id.cmp(&b.repo_id));
Ok(candidates)
}
pub async fn fan_out_bounded<T, R, F, Fut>(
items: Vec<T>,
concurrency: usize,
f: F,
) -> Vec<Option<R>>
where
T: Send + 'static,
R: Send + 'static,
F: Fn(T) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Option<R>> + Send + 'static,
{
let len = items.len();
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
let f = Arc::new(f);
let mut set: tokio::task::JoinSet<(usize, Option<R>)> = tokio::task::JoinSet::new();
for (index, item) in items.into_iter().enumerate() {
let limiter = Arc::clone(&semaphore);
let f = Arc::clone(&f);
set.spawn(async move {
let Ok(_permit) = limiter.acquire_owned().await else {
return (index, None);
};
(index, f(item).await)
});
}
let mut slots: Vec<Option<R>> = (0..len).map(|_| None).collect();
while let Some(joined) = set.join_next().await {
if let Ok((index, result)) = joined
&& let Some(slot) = slots.get_mut(index)
{
*slot = result;
}
}
slots
}
fn zip_into_map<T: Eq + std::hash::Hash, R>(
keys: Vec<T>,
results: Vec<Option<R>>,
) -> HashMap<T, R> {
keys.into_iter()
.zip(results)
.filter_map(|(key, result)| result.map(|r| (key, r)))
.collect()
}
#[must_use]
pub async fn fetch_repo_sizes_concurrent(repo_ids: Vec<String>) -> HashMap<String, u64> {
let client = reqwest::Client::new();
let keys = repo_ids.clone();
let results = fan_out_bounded(repo_ids, 8, move |repo_id| {
let client = client.clone();
async move { fetch_repo_total_size(&repo_id, &client).await.ok() }
})
.await;
zip_into_map(keys, results)
}
pub async fn fetch_repo_size_summaries_concurrent(
repo_ids: Vec<String>,
token: Option<&str>,
) -> Result<HashMap<String, RepoSizeSummary>, FetchError> {
let client = crate::chunked::build_client(token)?;
let keys = repo_ids.clone();
let results = fan_out_bounded(repo_ids, 8, move |repo_id| {
let client = client.clone();
async move { fetch_repo_size_summary(&repo_id, &client).await.ok() }
})
.await;
Ok(zip_into_map(keys, results))
}
#[must_use]
pub async fn fetch_tags_concurrent(repo_ids: Vec<String>) -> HashMap<String, Vec<String>> {
let keys = repo_ids.clone();
let results = fan_out_bounded(repo_ids, 8, move |repo_id| async move {
fetch_model_card(&repo_id).await.ok().map(|card| card.tags)
})
.await;
zip_into_map(keys, results)
}
pub async fn fetch_model_card(model_id: &str) -> Result<ModelCardMetadata, FetchError> {
let client = reqwest::Client::new();
let url = format!("{HF_API_BASE}/{model_id}");
let response = client
.get(url.as_str()) .send()
.await
.map_err(|e| FetchError::Http(e.to_string()))?;
if !response.status().is_success() {
return Err(FetchError::Http(format!(
"HF API returned status {} for model {model_id}",
response.status()
)));
}
let detail: ApiModelDetail = response
.json()
.await
.map_err(|e| FetchError::Http(e.to_string()))?;
let (license, languages) = if let Some(card) = detail.card_data {
let langs = match card.language {
Some(ApiLanguage::Single(s)) => vec![s],
Some(ApiLanguage::Multiple(v)) => v,
None => Vec::new(),
};
(card.license, langs)
} else {
(None, Vec::new())
};
let gated = match detail.gated {
ApiGated::Bool(false) => GateStatus::Open,
ApiGated::Mode(ref mode) if mode.eq_ignore_ascii_case("manual") => GateStatus::Manual,
ApiGated::Bool(true) | ApiGated::Mode(_) => GateStatus::Auto,
};
Ok(ModelCardMetadata {
license,
pipeline_tag: detail.pipeline_tag,
tags: detail.tags,
library_name: detail.library_name,
languages,
gated,
})
}
pub async fn fetch_readme(
model_id: &str,
revision: Option<&str>,
token: Option<&str>,
) -> Result<Option<String>, FetchError> {
let rev = revision.unwrap_or("main");
let url = crate::chunked::build_download_url(model_id, rev, "README.md");
let client = crate::chunked::build_client(token)?;
let response = client
.get(url.as_str()) .send()
.await
.map_err(|e| FetchError::Http(format!("failed to fetch README for {model_id}: {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
return Err(FetchError::Http(format!(
"README request for {model_id} returned status {}",
response.status()
)));
}
let text = response
.text()
.await
.map_err(|e| FetchError::Http(format!("failed to read README for {model_id}: {e}")))?;
Ok(Some(text))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_8bit_variants() {
assert_eq!(normalize_quantization_terms("AWQ 8bit"), "AWQ 8-bit");
assert_eq!(normalize_quantization_terms("AWQ 8-bit"), "AWQ 8-bit");
assert_eq!(normalize_quantization_terms("AWQ int8"), "AWQ 8-bit");
assert_eq!(normalize_quantization_terms("AWQ INT8"), "AWQ 8-bit");
}
#[test]
fn normalize_4bit_variants() {
assert_eq!(normalize_quantization_terms("GPTQ 4bit"), "GPTQ 4-bit");
assert_eq!(normalize_quantization_terms("GPTQ INT4"), "GPTQ 4-bit");
assert_eq!(normalize_quantization_terms("GPTQ 4-bit"), "GPTQ 4-bit");
}
#[test]
fn normalize_fp8_variants() {
assert_eq!(normalize_quantization_terms("FP8"), "fp8");
assert_eq!(normalize_quantization_terms("float8"), "fp8");
assert_eq!(normalize_quantization_terms("fp8"), "fp8");
}
#[test]
fn normalize_passthrough() {
assert_eq!(normalize_quantization_terms("llama 3"), "llama 3");
assert_eq!(normalize_quantization_terms("RWKV-7"), "RWKV-7");
}
#[test]
fn gguf_source_backlinks_finds_source_url() {
let mut metadata = HashMap::new();
metadata.insert(
"general.source.url".to_owned(),
"https://huggingface.co/poolside/Laguna-XS-2.1".to_owned(),
);
metadata.insert("general.architecture".to_owned(), "llama".to_owned());
assert_eq!(
gguf_source_backlinks(&metadata),
vec!["https://huggingface.co/poolside/Laguna-XS-2.1"]
);
}
#[test]
fn gguf_source_backlinks_finds_base_model_repo_url() {
let mut metadata = HashMap::new();
metadata.insert(
"general.base_model.0.repo_url".to_owned(),
"https://huggingface.co/poolside/Laguna-XS-2.1".to_owned(),
);
assert_eq!(gguf_source_backlinks(&metadata).len(), 1);
}
#[test]
fn gguf_source_backlinks_finds_huggingface_repository_key() {
let mut metadata = HashMap::new();
metadata.insert(
"general.source.huggingface.repository".to_owned(),
"poolside/Laguna-XS-2.1".to_owned(),
);
assert_eq!(gguf_source_backlinks(&metadata).len(), 1);
}
#[test]
fn gguf_source_backlinks_ignores_unrelated_keys() {
let mut metadata = HashMap::new();
metadata.insert("general.architecture".to_owned(), "llama".to_owned());
metadata.insert("general.name".to_owned(), "Laguna-XS-2.1-GGUF".to_owned());
assert!(gguf_source_backlinks(&metadata).is_empty());
}
#[test]
fn classify_gguf_files_not_applicable_for_zero_or_one_file() {
assert_eq!(classify_gguf_files(&[]), GgufFileSetKind::NotApplicable);
assert_eq!(
classify_gguf_files(&["model-Q4_K_M.gguf"]),
GgufFileSetKind::NotApplicable
);
}
#[test]
fn classify_gguf_files_recognizes_a_complete_shard_set() {
let files = [
"model-00001-of-00003.gguf",
"model-00002-of-00003.gguf",
"model-00003-of-00003.gguf",
];
assert_eq!(
classify_gguf_files(&files),
GgufFileSetKind::Sharded {
first_shard: "model-00001-of-00003.gguf".to_owned()
}
);
}
#[test]
fn classify_gguf_files_recognizes_a_shard_set_regardless_of_listing_order() {
let files = [
"model-00003-of-00003.gguf",
"model-00001-of-00003.gguf",
"model-00002-of-00003.gguf",
];
assert_eq!(
classify_gguf_files(&files),
GgufFileSetKind::Sharded {
first_shard: "model-00001-of-00003.gguf".to_owned()
}
);
}
#[test]
fn classify_gguf_files_flags_quant_alternatives() {
let files = ["model-Q4_K_M.gguf", "model-Q5_K_M.gguf", "model-Q8_0.gguf"];
assert_eq!(
classify_gguf_files(&files),
GgufFileSetKind::QuantAlternatives
);
}
#[test]
fn classify_gguf_files_flags_a_missing_shard_index() {
let files = ["model-00001-of-00003.gguf", "model-00003-of-00003.gguf"];
assert_eq!(
classify_gguf_files(&files),
GgufFileSetKind::QuantAlternatives
);
}
#[test]
fn classify_gguf_files_flags_a_duplicate_shard_index() {
let files = [
"model-00001-of-00003.gguf",
"model-00001-of-00003.gguf",
"model-00003-of-00003.gguf",
];
assert_eq!(
classify_gguf_files(&files),
GgufFileSetKind::QuantAlternatives
);
}
#[test]
fn classify_gguf_files_flags_mismatched_prefixes() {
let files = ["model-a-00001-of-00002.gguf", "model-b-00002-of-00002.gguf"];
assert_eq!(
classify_gguf_files(&files),
GgufFileSetKind::QuantAlternatives
);
}
#[test]
fn classify_gguf_files_ignores_non_gguf_files() {
let files = [
"model-00001-of-00002.gguf",
"model-00002-of-00002.gguf",
"config.json",
"README.md",
];
assert_eq!(
classify_gguf_files(&files),
GgufFileSetKind::Sharded {
first_shard: "model-00001-of-00002.gguf".to_owned()
}
);
}
#[test]
fn gguf_size_range_computes_min_max_for_quant_alternatives() {
let files = vec![
("model-Q8_0.gguf", Some(20_000)),
("model-Q4_K_M.gguf", Some(10_000)),
("model-Q3_K_S.gguf", Some(14_000)),
];
assert_eq!(gguf_size_range(files), Some((10_000, 20_000)));
}
#[test]
fn gguf_size_range_returns_none_for_a_sharded_set() {
let files = vec![
("model-00001-of-00002.gguf", Some(10_000)),
("model-00002-of-00002.gguf", Some(10_000)),
];
assert_eq!(gguf_size_range(files), None);
}
#[test]
fn gguf_size_range_still_classifies_sharded_when_one_shard_has_no_known_size() {
let files = vec![
("model-00001-of-00003.gguf", Some(5_000)),
("model-00002-of-00003.gguf", None),
("model-00003-of-00003.gguf", Some(5_000)),
];
assert_eq!(gguf_size_range(files), None);
}
#[test]
fn gguf_size_range_excludes_unsized_files_from_min_max_but_keeps_classifying_on_all() {
let files = vec![
("model-Q8_0.gguf", Some(20_000)),
("model-Q4_K_M.gguf", None),
("model-Q3_K_S.gguf", Some(14_000)),
];
assert_eq!(gguf_size_range(files), Some((14_000, 20_000)));
}
fn repo_file(filename: &str, size: u64) -> crate::repo::RepoFile {
crate::repo::RepoFile {
filename: filename.to_owned(),
size: Some(size),
sha256: None,
}
}
#[test]
fn pick_backlink_representative_picks_first_shard_for_a_sharded_set() {
let files = vec![
repo_file("model-00001-of-00003.gguf", 5_000),
repo_file("model-00002-of-00003.gguf", 5_000),
repo_file("model-00003-of-00003.gguf", 1_000),
];
assert_eq!(
pick_backlink_representative(&files).map(|f| f.filename.as_str()),
Some("model-00001-of-00003.gguf")
);
}
#[test]
fn pick_backlink_representative_picks_smallest_for_quant_alternatives() {
let files = vec![
repo_file("model-Q8_0.gguf", 20_000),
repo_file("model-Q4_K_M.gguf", 10_000),
repo_file("model-Q3_K_S.gguf", 14_000),
];
assert_eq!(
pick_backlink_representative(&files).map(|f| f.filename.as_str()),
Some("model-Q4_K_M.gguf")
);
}
#[test]
fn pick_backlink_representative_picks_the_lone_file() {
let files = vec![repo_file("model.gguf", 10_000)];
assert_eq!(
pick_backlink_representative(&files).map(|f| f.filename.as_str()),
Some("model.gguf")
);
}
#[test]
fn pick_backlink_representative_returns_none_for_no_gguf_files() {
assert!(pick_backlink_representative(&[]).is_none());
}
#[tokio::test]
async fn fan_out_bounded_preserves_item_order() {
let items = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
let out =
fan_out_bounded(items, 2, |item| async move { Some(format!("{item}-done")) }).await;
assert_eq!(
out,
vec![
Some("a-done".to_owned()),
Some("b-done".to_owned()),
Some("c-done".to_owned()),
]
);
}
#[tokio::test]
async fn fan_out_bounded_keeps_a_none_slot_for_per_item_failures() {
let items = vec!["keep".to_owned(), "drop".to_owned(), "keep2".to_owned()];
let out = fan_out_bounded(items, 2, |item| async move {
if item == "drop" { None } else { Some(item) }
})
.await;
assert_eq!(
out,
vec![Some("keep".to_owned()), None, Some("keep2".to_owned()),]
);
}
#[tokio::test]
async fn zip_into_map_drops_none_slots_and_keys_by_item() {
let keys = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
let results = vec![Some(1), None, Some(3)];
let map = zip_into_map(keys, results);
assert_eq!(map.len(), 2);
assert_eq!(map.get("a"), Some(&1));
assert!(!map.contains_key("b"));
assert_eq!(map.get("c"), Some(&3));
}
#[tokio::test]
async fn fan_out_bounded_respects_concurrency_limit() {
let in_flight = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let max_seen = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let items: Vec<u32> = (0..6).collect();
let in_flight_for_closure = Arc::clone(&in_flight);
let max_seen_for_closure = Arc::clone(&max_seen);
let out = fan_out_bounded(items, 2, move |item| {
let in_flight = Arc::clone(&in_flight_for_closure);
let max_seen = Arc::clone(&max_seen_for_closure);
async move {
let now = in_flight.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
max_seen.fetch_max(now, std::sync::atomic::Ordering::SeqCst);
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
in_flight.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
Some(item)
}
})
.await;
assert_eq!(out.len(), 6);
assert!(
max_seen.load(std::sync::atomic::Ordering::SeqCst) <= 2,
"observed more than 2 tasks in flight at once"
);
}
#[tokio::test]
async fn fetch_repo_size_summaries_concurrent_reports_a_malformed_token_loudly() {
let result =
fetch_repo_size_summaries_concurrent(vec!["org/model".to_owned()], Some("bad\ntoken"))
.await;
assert!(
result.is_err(),
"a malformed token must be reported as an error"
);
}
}