use serde_json::Value;
use crate::schema::{
CostModel, ModelCapability, ModelSchema, ModelSource, PerformanceEnvelope, 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 native = crate::backend::local::has_native_backend(&model_type);
let bits = quantization_bits(&config);
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: bits.map(|b| format!("{b}bit")),
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 native {
ModelSource::Mlx {
hf_repo: repo.to_string(),
hf_weight_file: None,
}
} else {
ModelSource::VllmMlx {
endpoint: "http://localhost:8000".to_string(),
model_name: repo.to_string(),
}
},
tags: {
let mut t = vec![
"derived".to_string(),
"local".to_string(),
model_type.clone(),
];
t.push(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,
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 has_weights = files
.map(|f| {
f.iter()
.filter_map(|f| f.get("rfilename").and_then(Value::as_str))
.any(is_weight_file)
})
.unwrap_or(false);
Ok(RepoListing {
total_bytes,
has_weights,
observed: true,
})
}
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
}
fn quantization_bits(config: &Value) -> Option<u64> {
config
.get("quantization")
.and_then(|q| q.get("bits"))
.and_then(Value::as_u64)
}
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<u64>) -> 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 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
);
}
}