use serde_json::Value;
use crate::schema::{
CostModel, ModelCapability, ModelSchema, ModelSource, PerformanceEnvelope, Quantization,
TrustTier,
};
use crate::InferenceError;
#[derive(Debug, Clone)]
pub struct DerivedModel {
pub schema: ModelSchema,
pub model_type: String,
pub native: bool,
}
pub async fn derive_from_hf_repo(repo: &str) -> Result<DerivedModel, InferenceError> {
let repo = repo.trim().trim_matches('/');
if repo.split('/').count() != 2 || repo.split('/').any(str::is_empty) {
return Err(InferenceError::InferenceFailed(format!(
"`{repo}` is not a HuggingFace repo id — expected `org/name`"
)));
}
let config = fetch_config(repo).await?;
let listing = fetch_repo_listing(repo).await.unwrap_or_default();
let size_bytes = listing.total_bytes;
if listing.observed && !listing.has_weights {
return Err(InferenceError::InferenceFailed(format!(
"{repo}: no model weights found (no .safetensors, .bin, or .gguf files). \
If this is a base repo that only holds a config, use one of its \
quantized conversions instead."
)));
}
let model_type = config
.get("model_type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
if model_type.is_empty() {
return Err(InferenceError::InferenceFailed(format!(
"{repo}: config.json declares no `model_type`, so CAR cannot tell \
which backend should serve it"
)));
}
let gguf_only = listing.observed && !listing.has_safetensors && !listing.gguf_files.is_empty();
let gguf_weight_file = if gguf_only {
Some(single_loadable_gguf(repo, &model_type, &listing)?)
} else {
None
};
let declared = quantization_of(&config);
let quantization = match &gguf_weight_file {
Some(file) => Quantization::from_gguf_filename(file),
None => declared.known().cloned(),
};
let bits = quantization.as_ref().and_then(|q| q.bits);
let native = gguf_weight_file.is_none()
&& crate::backend::local::has_native_backend(&model_type)
&& declared.decodable();
let size_mb = size_bytes / 1_000_000;
let schema = ModelSchema {
id: derive_id(repo, bits),
name: basename(repo).to_string(),
provider: org(repo).to_ascii_lowercase(),
family: model_type.clone(),
version: String::new(),
capabilities: capabilities(&config),
context_length: context_length(&config),
max_output_tokens: None,
param_count: String::new(),
quantization,
performance: PerformanceEnvelope::default(),
cost: CostModel {
size_mb: (size_mb > 0).then_some(size_mb),
ram_mb: (size_mb > 0).then(|| size_mb + size_mb / 4),
..Default::default()
},
source: if let Some(file) = gguf_weight_file.clone() {
ModelSource::Local {
hf_repo: repo.to_string(),
hf_filename: file,
tokenizer_repo: repo.to_string(),
}
} else if native {
ModelSource::Mlx {
hf_repo: repo.to_string(),
hf_weight_file: None,
}
} else {
ModelSource::ManagedVllmMlx {
hf_repo: repo.to_string(),
hf_weight_file: None,
}
},
tags: {
let mut t = vec![
"derived".to_string(),
"local".to_string(),
model_type.clone(),
];
t.push(
if gguf_weight_file.is_some() {
"gguf"
} else if native {
"native-mlx"
} else {
"vllm-mlx"
}
.to_string(),
);
if is_moe(&config) {
t.push("moe".to_string());
}
t
},
supported_params: Vec::new(),
public_benchmarks: Vec::new(),
trust_tier: TrustTier::Community,
deprecated: false,
available: false,
weights_ready: false,
};
Ok(DerivedModel {
schema,
model_type,
native,
})
}
async fn fetch_config(repo: &str) -> Result<Value, InferenceError> {
let url = format!("https://huggingface.co/{repo}/resolve/main/config.json");
crate::tls_client::model_download_client()
.get(&url)
.send()
.await
.map_err(|e| InferenceError::InferenceFailed(format!("fetch {repo} config.json: {e}")))?
.error_for_status()
.map_err(|e| {
InferenceError::InferenceFailed(format!(
"{repo}: no readable config.json ({e}) — check the repo id"
))
})?
.json()
.await
.map_err(|e| InferenceError::InferenceFailed(format!("parse {repo} config.json: {e}")))
}
#[derive(Debug, Default)]
struct RepoListing {
total_bytes: u64,
has_weights: bool,
has_safetensors: bool,
gguf_files: Vec<String>,
has_tokenizer_json: bool,
observed: bool,
}
async fn fetch_repo_listing(repo: &str) -> Result<RepoListing, InferenceError> {
let url = format!("https://huggingface.co/api/models/{repo}?blobs=true");
let info: Value = crate::tls_client::model_download_client()
.get(&url)
.send()
.await
.map_err(|e| InferenceError::InferenceFailed(e.to_string()))?
.error_for_status()
.map_err(|e| InferenceError::InferenceFailed(e.to_string()))?
.json()
.await
.map_err(|e| InferenceError::InferenceFailed(e.to_string()))?;
let files = info.get("siblings").and_then(Value::as_array);
let total_bytes = files
.map(|f| {
f.iter()
.filter_map(|f| f.get("size").and_then(Value::as_u64))
.sum()
})
.unwrap_or(0);
let names: Vec<&str> = files
.map(|f| {
f.iter()
.filter_map(|f| f.get("rfilename").and_then(Value::as_str))
.collect()
})
.unwrap_or_default();
let lower = |n: &str| n.to_ascii_lowercase();
Ok(RepoListing {
total_bytes,
has_weights: names.iter().any(|n| is_weight_file(n)),
has_safetensors: names.iter().any(|n| lower(n).ends_with(".safetensors")),
gguf_files: names
.iter()
.filter(|n| lower(n).ends_with(".gguf"))
.map(|n| (*n).to_string())
.collect(),
has_tokenizer_json: names
.iter()
.any(|n| lower(n).rsplit('/').next() == Some("tokenizer.json")),
observed: true,
})
}
fn single_loadable_gguf(
repo: &str,
model_type: &str,
listing: &RepoListing,
) -> Result<String, InferenceError> {
if !crate::backend::local::gguf_backend_serves(model_type) {
let alternative = if cfg!(all(
target_os = "macos",
target_arch = "aarch64",
not(car_skip_mlx)
)) {
"this machine runs local models through MLX, which reads safetensors — \
an `mlx-community` conversion of this model is the one you want"
} else {
"this machine runs local models through CUDA on safetensors, and CAR's \
GGUF path implements only Qwen3"
};
return Err(InferenceError::InferenceFailed(format!(
"{repo}: GGUF weights for '{model_type}', which no backend on this build \
loads. {alternative}."
)));
}
let shard = |name: &str| {
let lower = name.to_ascii_lowercase();
lower.contains("-of-") && lower.ends_with(".gguf")
};
let whole: Vec<&String> = listing.gguf_files.iter().filter(|f| !shard(f)).collect();
let Some(file) = whole.first() else {
return Err(InferenceError::InferenceFailed(format!(
"{repo}: its GGUF weights are split across shards, which CAR cannot \
assemble. Use a single-file quantization from the same repo."
)));
};
if whole.len() > 1 {
let mut names: Vec<&str> = whole.iter().map(|f| f.as_str()).collect();
names.sort_unstable();
return Err(InferenceError::InferenceFailed(format!(
"{repo}: carries {} GGUF quantizations ({}). CAR cannot pick one for \
you — register the specific file you want.",
names.len(),
names.join(", ")
)));
}
if !listing.has_tokenizer_json {
return Err(InferenceError::InferenceFailed(format!(
"{repo}: GGUF weights but no tokenizer.json, which the local loader \
downloads by repo name. Point CAR at a repo that carries both, or \
register a schema naming the tokenizer repo explicitly."
)));
}
Ok((*file).clone())
}
fn is_weight_file(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
[".safetensors", ".gguf", ".bin", ".npz"]
.iter()
.any(|ext| lower.ends_with(ext))
}
fn nested<'a>(config: &'a Value, key: &str) -> Option<&'a Value> {
config.get(key).or_else(|| {
config
.get("text_config")
.and_then(|t| t.get(key))
.filter(|v| !v.is_null())
})
}
fn context_length(config: &Value) -> usize {
nested(config, "max_position_embeddings")
.and_then(Value::as_u64)
.unwrap_or(32_768) as usize
}
#[derive(Debug, PartialEq, Eq)]
enum DeclaredQuantization {
Absent,
Known(Quantization),
Unreadable,
}
impl DeclaredQuantization {
fn known(&self) -> Option<&Quantization> {
match self {
Self::Known(q) => Some(q),
_ => None,
}
}
fn decodable(&self) -> bool {
match self {
Self::Absent => true,
Self::Known(q) => crate::backend::local::quantization_is_decodable(Some(q)),
Self::Unreadable => false,
}
}
}
fn quantization_of(config: &Value) -> DeclaredQuantization {
let Some(block) = config
.get("quantization")
.filter(|v| v.is_object())
.or_else(|| config.get("quantization_config").filter(|v| v.is_object()))
else {
return match config
.get("quantization")
.or_else(|| config.get("quantization_config"))
{
Some(v) if !v.is_null() => DeclaredQuantization::Unreadable,
_ => DeclaredQuantization::Absent,
};
};
if let Some(method) = block.get("quant_method").and_then(Value::as_str) {
let bits = block
.get("bits")
.and_then(Value::as_u64)
.and_then(|b| u8::try_from(b).ok());
return DeclaredQuantization::Known(Quantization {
bits,
scheme: crate::schema::QuantScheme::Unknown,
group_size: block
.get("group_size")
.and_then(Value::as_u64)
.and_then(|g| u32::try_from(g).ok()),
label: method.to_ascii_lowercase(),
});
}
let bits = block
.get("bits")
.and_then(Value::as_u64)
.and_then(|b| u8::try_from(b).ok());
let group_size = block
.get("group_size")
.and_then(Value::as_u64)
.and_then(|g| u32::try_from(g).ok());
let mode = block.get("mode").and_then(Value::as_str);
match Quantization::from_mlx_config(bits, group_size, mode) {
Some(q) => DeclaredQuantization::Known(q),
None => DeclaredQuantization::Unreadable,
}
}
fn is_moe(config: &Value) -> bool {
nested(config, "num_experts")
.and_then(Value::as_u64)
.is_some_and(|n| n > 1)
}
fn capabilities(config: &Value) -> Vec<ModelCapability> {
let mut caps = vec![
ModelCapability::Generate,
ModelCapability::Code,
ModelCapability::Reasoning,
ModelCapability::Summarize,
ModelCapability::ToolUse,
ModelCapability::MultiToolCall,
];
let text_only = config
.get("language_model_only")
.and_then(Value::as_bool)
.unwrap_or(false);
let has_vision_tower = config.get("vision_config").is_some()
|| config.get("image_token_id").is_some()
|| config.get("image_token_index").is_some();
if has_vision_tower && !text_only {
caps.push(ModelCapability::Vision);
}
caps
}
fn org(repo: &str) -> &str {
repo.split('/').next().unwrap_or("custom")
}
fn basename(repo: &str) -> &str {
repo.rsplit('/').next().unwrap_or(repo)
}
fn derive_id(repo: &str, bits: Option<u8>) -> String {
let base = basename(repo).to_ascii_lowercase();
match bits {
Some(b) => format!("custom/{base}:{b}bit"),
None => format!("custom/{base}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn rejects_a_non_repo_id() {
let err = tokio::runtime::Runtime::new()
.unwrap()
.block_on(derive_from_hf_repo("Qwen3.8-27B"))
.unwrap_err();
assert!(err.to_string().contains("org/name"), "got {err}");
}
#[test]
fn context_falls_back_to_text_config() {
let c = json!({ "text_config": { "max_position_embeddings": 262144 } });
assert_eq!(context_length(&c), 262_144);
}
#[test]
fn vision_is_claimed_only_when_declared() {
let text_only = json!({ "model_type": "qwen3" });
assert!(!capabilities(&text_only).contains(&ModelCapability::Vision));
let vlm = json!({ "model_type": "qwen3_vl", "vision_config": {} });
assert!(capabilities(&vlm).contains(&ModelCapability::Vision));
let stripped = json!({
"model_type": "qwen3_5",
"vision_config": { "depth": 27 },
"image_token_id": 248056,
"language_model_only": true
});
assert!(
!capabilities(&stripped).contains(&ModelCapability::Vision),
"a language_model_only conversion must not claim vision"
);
}
#[test]
fn quantization_block_is_read_whole_or_not_at_all() {
let full = json!({
"quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" }
});
let q = quantization_of(&full)
.known()
.cloned()
.expect("a real block must parse");
assert_eq!(q.bits, Some(8));
assert_eq!(q.group_size, Some(32), "group size must not be dropped");
assert_eq!(q.scheme, crate::schema::QuantScheme::BlockScaledFloat);
for absent in [json!({ "quantization": null }), json!({})] {
assert_eq!(
quantization_of(&absent),
DeclaredQuantization::Absent,
"must not invent a scheme from {absent}"
);
assert!(quantization_of(&absent).decodable());
}
for unreadable in [
json!({ "quantization": "Q4_K_M" }),
json!({ "quantization": {} }),
json!({ "quantization": { "bits": 4.0 } }),
json!({ "quantization": { "bits": "4" } }),
] {
assert_eq!(
quantization_of(&unreadable),
DeclaredQuantization::Unreadable,
"a present-but-unreadable block is not an unquantized model: {unreadable}"
);
assert!(
!quantization_of(&unreadable).decodable(),
"must not route native on a block it could not read: {unreadable}"
);
}
}
#[test]
fn an_undecodable_layout_fails_admission_not_weight_loading() {
use crate::backend::local::native_backend_serves;
let affine = quantization_of(&json!({
"quantization": { "bits": 4, "group_size": 64 }
}))
.known()
.cloned();
let mxfp4 = quantization_of(&json!({
"quantization": { "bits": 4, "group_size": 32, "mode": "mxfp4" }
}))
.known()
.cloned();
assert_eq!(
native_backend_serves("qwen3", affine.as_ref()),
crate::backend::local::has_native_backend("qwen3")
);
assert!(!native_backend_serves("qwen3", mxfp4.as_ref()));
}
#[test]
fn both_spellings_of_the_quantization_block_are_read() {
let q = quantization_of(&json!({
"quantization_config": { "bits": 4, "group_size": 32, "mode": "mxfp4" }
}))
.known()
.cloned()
.expect("quantization_config must be read");
assert_eq!(q.scheme, crate::schema::QuantScheme::BlockScaledFloat);
assert!(!crate::backend::local::quantization_is_decodable(Some(&q)));
}
#[test]
fn huggingface_quant_methods_are_refused() {
for method in ["awq", "gptq", "bitsandbytes"] {
let q = quantization_of(&json!({
"quantization_config": { "quant_method": method, "bits": 4, "group_size": 128 }
}))
.known()
.cloned()
.unwrap_or_else(|| panic!("{method} block must be recognized as quantized"));
assert_eq!(q.label, method);
assert_eq!(q.bits, Some(4));
assert_eq!(q.group_size, Some(128));
assert!(
!crate::backend::local::quantization_is_decodable(Some(&q)),
"{method} has no native path and must not be admitted"
);
}
}
fn gguf_listing(files: &[&str], tokenizer: bool) -> RepoListing {
RepoListing {
total_bytes: 1,
has_weights: files.iter().any(|f| is_weight_file(f)),
has_safetensors: files
.iter()
.any(|f| f.to_ascii_lowercase().ends_with(".safetensors")),
gguf_files: files
.iter()
.filter(|f| f.to_ascii_lowercase().ends_with(".gguf"))
.map(|f| (*f).to_string())
.collect(),
has_tokenizer_json: tokenizer,
observed: true,
}
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
#[test]
fn a_single_gguf_with_a_tokenizer_is_derivable() {
let listing = gguf_listing(&["Qwen3-8B-Q4_K_M.gguf", "tokenizer.json"], true);
let file =
single_loadable_gguf("Qwen/Qwen3-8B-GGUF", "qwen3", &listing).expect("derivable");
assert_eq!(file, "Qwen3-8B-Q4_K_M.gguf");
let q = Quantization::from_gguf_filename(&file).unwrap();
assert_eq!(q.label, "Q4_K_M");
assert_eq!(q.scheme, crate::schema::QuantScheme::KQuantMixed);
}
#[test]
fn gguf_is_refused_where_no_backend_reads_it() {
let listing = gguf_listing(&["m-Q4_K_M.gguf", "tokenizer.json"], true);
let err = single_loadable_gguf("acme/Llama-3-8B-GGUF", "llama", &listing)
.unwrap_err()
.to_string();
assert!(err.contains("llama"), "must name the architecture: {err}");
assert!(
err.contains("safetensors"),
"must point at the format the machine's real backend reads: {err}"
);
let qwen3 = single_loadable_gguf("Qwen/Qwen3-8B-GGUF", "qwen3", &listing);
if cfg!(all(
target_os = "macos",
target_arch = "aarch64",
not(car_skip_mlx)
)) {
let err = qwen3.unwrap_err().to_string();
assert!(err.contains("MLX"), "a Mac must be steered to MLX: {err}");
} else {
assert!(qwen3.is_ok(), "Qwen3 GGUF is served off Apple Silicon");
}
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
#[test]
fn underivable_gguf_repos_are_refused_with_the_reason() {
let err = single_loadable_gguf(
"acme/model-GGUF",
"qwen3",
&gguf_listing(&["m-Q4_K_M.gguf"], false),
)
.unwrap_err()
.to_string();
assert!(err.contains("tokenizer.json"), "got {err}");
let err = single_loadable_gguf(
"acme/model-GGUF",
"qwen3",
&gguf_listing(&["m-Q4_K_M.gguf", "m-Q8_0.gguf"], true),
)
.unwrap_err()
.to_string();
assert!(err.contains("Q4_K_M") && err.contains("Q8_0"), "got {err}");
let err = single_loadable_gguf(
"acme/model-GGUF",
"qwen3",
&gguf_listing(
&[
"m-Q4_K_M-00001-of-00002.gguf",
"m-Q4_K_M-00002-of-00002.gguf",
],
true,
),
)
.unwrap_err()
.to_string();
assert!(err.contains("split across shards"), "got {err}");
}
#[test]
fn safetensors_alongside_gguf_is_not_a_gguf_repo() {
let listing = gguf_listing(&["model.safetensors", "m-Q4_K_M.gguf"], true);
assert!(
!(listing.observed && !listing.has_safetensors && !listing.gguf_files.is_empty()),
"safetensors must take precedence"
);
}
#[test]
fn id_keeps_quantizations_distinct() {
assert_eq!(
derive_id("mlx-community/Qwen3.8-27B-4bit", Some(4)),
"custom/qwen3.8-27b-4bit:4bit"
);
assert_ne!(
derive_id("mlx-community/Qwen3.8-27B-4bit", Some(4)),
derive_id("mlx-community/Qwen3.8-27B-8bit", Some(8))
);
}
#[test]
fn weight_files_are_recognized_across_formats() {
for name in [
"model-00001-of-00003.safetensors",
"model.gguf",
"pytorch_model.bin",
"weights.npz",
"MODEL.SAFETENSORS",
] {
assert!(is_weight_file(name), "{name} should count as weights");
}
for name in [
"config.json",
"tokenizer.json",
"README.md",
"chat_template.jinja",
".gitattributes",
] {
assert!(!is_weight_file(name), "{name} is not weights");
}
}
#[test]
fn an_unobserved_listing_does_not_trigger_the_weights_check() {
let unobserved = RepoListing::default();
assert!(!unobserved.observed);
assert!(!unobserved.has_weights);
assert!(
!unobserved.observed || unobserved.has_weights,
"a failed listing must not be reported as a weightless repo"
);
}
#[test]
fn moe_needs_more_than_one_expert() {
assert!(is_moe(&json!({ "num_experts": 128 })));
assert!(!is_moe(&json!({ "num_experts": 1 })));
assert!(!is_moe(&json!({})));
}
#[test]
fn unknown_architectures_are_not_native() {
assert!(!crate::backend::local::has_native_backend("qwen3_5"));
assert!(!crate::backend::local::has_native_backend("qwen3_5_moe"));
assert!(!crate::backend::local::has_native_backend("glm4_moe_lite"));
}
#[test]
fn known_architectures_are_native_on_apple_silicon() {
let expected = cfg!(all(
target_os = "macos",
target_arch = "aarch64",
not(car_skip_mlx)
));
assert_eq!(
crate::backend::local::has_native_backend("qwen3_moe"),
expected
);
assert_eq!(
crate::backend::local::has_native_backend("gemma4_unified"),
expected
);
}
}