use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::download::{DownloadEvent, ProgressSink};
use crate::schema::*;
use crate::InferenceError;
#[derive(Debug, Clone, Default)]
pub struct ModelFilter {
pub capabilities: Vec<ModelCapability>,
pub max_size_mb: Option<u64>,
pub max_latency_ms: Option<u64>,
pub max_cost_per_mtok: Option<f64>,
pub tags: Vec<String>,
pub provider: Option<String>,
pub local_only: bool,
pub available_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelUpgrade {
pub from_id: String,
pub from_name: String,
pub to_id: String,
pub to_name: String,
pub reason: String,
pub target_runtime: Option<String>,
pub target_runtime_requirement: Option<String>,
pub minimum_runtimes: Vec<ModelRuntimeRequirement>,
pub target_available: bool,
pub target_pullable: bool,
pub remove_old_supported: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRuntimeRequirement {
pub name: String,
pub minimum_version: String,
}
#[derive(Clone)]
pub struct UnifiedRegistry {
models_dir: PathBuf,
models: HashMap<String, ModelSchema>,
user_config_ids: HashSet<String>,
user_config_path: PathBuf,
ambient_progress: ProgressSink,
}
#[derive(Debug, Clone, Deserialize)]
struct ModelUpgradeRule {
from_ids: Vec<String>,
to_id: String,
reason: String,
target_runtime: Option<String>,
target_runtime_requirement: Option<String>,
#[serde(default)]
minimum_runtimes: Vec<ModelRuntimeRequirement>,
#[serde(default = "default_remove_old_after_available")]
remove_old_after_available: bool,
}
fn default_remove_old_after_available() -> bool {
true
}
fn proprietary_auth_available(
model_id: &str,
schema_provider: &str,
source_provider: &str,
auth: &ProprietaryAuth,
parslee_oauth_available: bool,
resolved: Option<&std::collections::HashMap<String, bool>>,
) -> bool {
if crate::openrouter::is_curated_managed_gateway_alias(model_id)
&& crate::openrouter::gateway_unconfigured()
{
return false;
}
match auth {
ProprietaryAuth::ApiKeyEnv { env_var } | ProprietaryAuth::BearerTokenEnv { env_var } => {
match resolved.and_then(|m| m.get(env_var).copied()) {
Some(known) => known,
None => car_secrets::resolve_env_or_keychain(env_var).is_some(),
}
}
ProprietaryAuth::OAuth2Pkce { .. } => {
schema_provider.eq_ignore_ascii_case("parslee")
&& source_provider.eq_ignore_ascii_case("parslee")
&& parslee_oauth_available
}
}
}
fn model_upgrade_rules() -> Vec<ModelUpgradeRule> {
serde_json::from_str(include_str!("../assets/model-upgrades.json"))
.expect("built-in model-upgrades.json should parse")
}
impl UnifiedRegistry {
pub fn new(models_dir: PathBuf) -> Self {
let catalog_public_key = std::env::var("CAR_CATALOG_PUBKEY").ok();
Self::new_with_catalog_public_key(models_dir, catalog_public_key.as_deref())
}
fn new_with_catalog_public_key(models_dir: PathBuf, catalog_public_key: Option<&str>) -> Self {
let user_config_path = models_dir
.parent()
.unwrap_or(&models_dir)
.join("models.json");
let mut registry = Self {
models_dir,
models: HashMap::new(),
user_config_ids: HashSet::new(),
user_config_path,
ambient_progress: ProgressSink::none(),
};
registry.load_builtin_catalog();
for schema in crate::catalog::load_cache(
&crate::catalog::cache_path(®istry.models_dir),
catalog_public_key,
) {
registry.register_project_model(schema);
}
for schema in
crate::discovery::load_cache(&crate::discovery::cache_path(®istry.models_dir))
{
if !registry.models.contains_key(&schema.id) {
registry.register(schema);
}
}
registry.refresh_availability();
let _ = registry.load_user_config();
registry.discover_on_disk_models();
registry
}
#[cfg(test)]
pub fn new_empty(models_dir: PathBuf) -> Self {
let user_config_path = models_dir
.parent()
.unwrap_or(&models_dir)
.join("models.json");
Self {
models_dir,
models: HashMap::new(),
user_config_ids: HashSet::new(),
user_config_path,
ambient_progress: ProgressSink::none(),
}
}
fn discover_on_disk_models(&mut self) {
let entries = match std::fs::read_dir(&self.models_dir) {
Ok(e) => e,
Err(_) => return,
};
let known: std::collections::HashSet<String> = self
.models
.values()
.map(|m| m.name.to_ascii_lowercase())
.collect();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
else {
continue;
};
if known.contains(&name.to_ascii_lowercase()) {
continue;
}
let Some(schema) = synthesize_local_schema(&name, &path) else {
continue;
};
tracing::info!(
id = %schema.id,
name = %name,
"auto-discovered uncatalogued local model under models_dir (car-releases#62)"
);
self.register(schema);
}
}
pub fn register(&mut self, mut schema: ModelSchema) {
if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
warn!(id = %schema.id, "ignoring user registration for reserved Parslee-managed alias");
return;
}
schema.mark_user_registered();
self.register_preserving_trust(schema);
}
pub(crate) fn register_project_model(&mut self, schema: ModelSchema) {
self.register_preserving_trust(schema);
}
fn register_preserving_trust(&mut self, mut schema: ModelSchema) {
if schema.is_mlx() {
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
{
schema.available = if schema.tags.contains(&"speech".to_string()) {
speech_mlx_available()
} else if let ModelSource::Mlx { ref hf_repo, .. } = schema.source {
let mlx_dir = self.models_dir.join(&schema.name);
mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty()
} else {
let mlx_dir = self.models_dir.join(&schema.name);
mlx_dir_has_weights(&mlx_dir)
};
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
{
schema.available = false;
}
} else if schema.is_vllm_mlx() {
schema.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available;
} else if matches!(schema.source, ModelSource::WhisperCpp { .. }) {
schema.available = true;
} else if matches!(schema.source, ModelSource::WindowsSpeech {}) {
schema.available = cfg!(target_os = "windows");
} else if schema.is_local() {
let local_path = self.models_dir.join(&schema.name).join("model.gguf");
schema.available = local_path.exists();
} else if schema.is_remote() {
schema.available = match schema.source {
ModelSource::RemoteApi {
protocol: crate::schema::ApiProtocol::OpenRouter,
..
} => crate::openrouter::credential_source().is_some(),
ModelSource::RemoteApi {
ref api_key_env, ..
} => car_secrets::resolve_env_or_keychain(api_key_env).is_some(),
ModelSource::Proprietary {
ref provider,
ref auth,
..
} => proprietary_auth_available(
&schema.id,
&schema.provider,
provider,
auth,
car_auth::access_token_is_available(),
None,
),
_ => schema.available,
};
}
schema.weights_ready = if schema.is_mlx() {
mlx_dir_has_weights(&self.models_dir.join(&schema.name))
} else if let ModelSource::WhisperCpp { model } = &schema.source {
car_whisper::model_cached(model)
} else if matches!(schema.source, ModelSource::WindowsSpeech {}) {
true
} else if schema.is_local() {
self.models_dir
.join(&schema.name)
.join("model.gguf")
.exists()
} else {
true
};
info!(
id = %schema.id,
name = %schema.name,
available = schema.available,
weights_ready = schema.weights_ready,
"registered model"
);
self.models.insert(schema.id.clone(), schema);
}
pub fn register_user_model(&mut self, mut schema: ModelSchema) {
if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
warn!(id = %schema.id, "ignoring persisted user model for reserved Parslee-managed alias");
return;
}
schema.mark_user_registered();
let id = schema.id.clone();
self.register_preserving_trust(schema);
self.user_config_ids.insert(id);
}
pub fn unregister(&mut self, id: &str) -> Option<ModelSchema> {
let removed = self.models.remove(id);
if let Some(ref m) = removed {
info!(id = %m.id, "unregistered model");
}
removed
}
pub fn unregister_user_model(&mut self, id: &str) -> Option<ModelSchema> {
if !self.user_config_ids.remove(id) {
return None;
}
self.unregister(id)
}
pub fn list(&self) -> Vec<&ModelSchema> {
let mut models: Vec<&ModelSchema> = self.models.values().collect();
models.sort_by(|a, b| a.id.cmp(&b.id));
models
}
pub fn query(&self, filter: &ModelFilter) -> Vec<&ModelSchema> {
self.models
.values()
.filter(|m| {
if !filter.capabilities.iter().all(|c| m.has_capability(*c)) {
return false;
}
if let Some(max) = filter.max_size_mb {
if m.size_mb() > max && m.is_local() {
return false;
}
}
if let Some(max) = filter.max_latency_ms {
if let Some(p50) = m.performance.latency_p50_ms {
if p50 > max {
return false;
}
}
}
if let Some(max) = filter.max_cost_per_mtok {
if let Some(cost) = m.cost.output_per_mtok {
if cost > max {
return false;
}
}
}
if !filter.tags.iter().all(|t| m.tags.contains(t)) {
return false;
}
if let Some(ref p) = filter.provider {
if &m.provider != p {
return false;
}
}
if filter.local_only && !m.is_local() {
return false;
}
if filter.available_only && !m.available_now() {
return false;
}
true
})
.collect()
}
pub fn query_by_capability(&self, cap: ModelCapability) -> Vec<&ModelSchema> {
self.query(&ModelFilter {
capabilities: vec![cap],
..Default::default()
})
}
pub fn available_upgrades(&self) -> Vec<ModelUpgrade> {
let mut upgrades = Vec::new();
for rule in model_upgrade_rules() {
let Some(from) = rule
.from_ids
.iter()
.find_map(|id| self.models.get(id.as_str()))
.filter(|schema| schema.available)
else {
continue;
};
let Some(to) = self.models.get(rule.to_id.as_str()) else {
continue;
};
upgrades.push(ModelUpgrade {
from_id: from.id.clone(),
from_name: from.name.clone(),
to_id: to.id.clone(),
to_name: to.name.clone(),
reason: rule.reason.clone(),
target_runtime: rule.target_runtime.clone(),
target_runtime_requirement: rule.target_runtime_requirement.clone(),
minimum_runtimes: rule.minimum_runtimes.clone(),
target_available: to.available,
target_pullable: matches!(
to.source,
ModelSource::Local { .. } | ModelSource::Mlx { .. }
),
remove_old_supported: matches!(
from.source,
ModelSource::Local { .. } | ModelSource::Mlx { .. }
) && rule.remove_old_after_available,
});
}
upgrades.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
upgrades.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
upgrades
}
pub fn get(&self, id: &str) -> Option<&ModelSchema> {
self.models.get(id)
}
pub fn registered_schema(&self, id: &str) -> Option<&ModelSchema> {
self.get(id)
}
pub fn all(&self) -> impl Iterator<Item = &ModelSchema> {
self.models.values()
}
pub fn find_by_name(&self, name: &str) -> Option<&ModelSchema> {
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
if !name.to_ascii_lowercase().ends_with("-mlx") {
if let Some(mlx_variant) = self
.models
.values()
.find(|m| m.name.eq_ignore_ascii_case(&format!("{name}-MLX")))
{
return Some(mlx_variant);
}
}
self.models
.values()
.find(|m| m.name.eq_ignore_ascii_case(name))
}
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
pub fn resolve_mlx_equivalent(&self, schema: &ModelSchema) -> Option<&ModelSchema> {
if schema.is_mlx() || schema.is_vllm_mlx() {
return None;
}
if !matches!(schema.source, ModelSource::Local { .. }) {
return None;
}
let primary_cap = schema.capabilities.first()?;
self.models.values().find(|m| {
m.is_mlx()
&& m.family == schema.family
&& m.param_count == schema.param_count
&& m.capabilities.contains(primary_cap)
})
}
pub async fn ensure_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
let sink = self.ambient_progress.clone();
self.ensure_local_with_progress(id, &sink).await
}
pub fn set_ambient_progress(&mut self, sink: ProgressSink) {
self.ambient_progress = sink;
}
pub async fn ensure_local_with_progress(
&self,
id: &str,
sink: &ProgressSink,
) -> Result<PathBuf, InferenceError> {
self.acquire_and_ensure(id, sink, false).await
}
pub async fn redownload_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
self.acquire_and_ensure(id, &ProgressSink::none(), true)
.await
}
async fn acquire_and_ensure(
&self,
id: &str,
sink: &ProgressSink,
force: bool,
) -> Result<PathBuf, InferenceError> {
let schema = self
.get(id)
.or_else(|| self.find_by_name(id))
.ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
let model_name = schema.name.clone();
let model_id = schema.id.clone();
let needed_mb = schema.size_mb();
let model_dir = self.models_dir.join(&schema.name);
let _guard = crate::download::acquire_model_lock(&model_id).await;
if let Err(e) = crate::download::check_disk_space(&model_dir, needed_mb) {
sink.emit(DownloadEvent::Failed { error: e.clone() });
return Err(InferenceError::DownloadFailed(e));
}
sink.emit(DownloadEvent::Started {
model: model_name.clone(),
total_files: 0,
total_mb: needed_mb,
});
let result = self.ensure_local_inner(id, sink, force).await;
match &result {
Ok(_) => sink.emit(DownloadEvent::Completed { model: model_name }),
Err(e) => sink.emit(DownloadEvent::Failed {
error: e.to_string(),
}),
}
result
}
async fn ensure_local_inner(
&self,
id: &str,
sink: &ProgressSink,
force: bool,
) -> Result<PathBuf, InferenceError> {
let schema = self
.get(id)
.or_else(|| self.find_by_name(id))
.ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
match &schema.source {
ModelSource::Local {
hf_repo,
hf_filename,
tokenizer_repo,
} => {
let model_dir = self.models_dir.join(&schema.name);
let model_path = model_dir.join("model.gguf");
let tokenizer_path = model_dir.join("tokenizer.json");
if !force
&& crate::download::cache_file_usable(&model_path)
&& crate::download::cache_file_usable(&tokenizer_path)
{
return Ok(model_dir);
}
std::fs::create_dir_all(&model_dir)?;
if !crate::download::cache_file_usable(&model_path) {
info!(model = %schema.name, repo = %hf_repo, "downloading model weights");
sink.emit(DownloadEvent::FileStarted {
filename: "model weights".into(),
index: 1,
total_files: 2,
size_mb: schema.size_mb(),
});
download_file(hf_repo, hf_filename, &model_path).await?;
sink.emit(DownloadEvent::FileCompleted {
filename: "model weights".into(),
});
}
if !crate::download::cache_file_usable(&tokenizer_path) {
info!(model = %schema.name, repo = %tokenizer_repo, "downloading tokenizer");
sink.emit(DownloadEvent::FileStarted {
filename: "tokenizer".into(),
index: 2,
total_files: 2,
size_mb: 0,
});
download_file(tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
sink.emit(DownloadEvent::FileCompleted {
filename: "tokenizer".into(),
});
}
Ok(model_dir)
}
ModelSource::Mlx {
hf_repo,
hf_weight_file,
} => {
let model_dir = self.models_dir.join(&schema.name);
let config_path = model_dir.join("config.json");
let is_diffusers = schema.capabilities.iter().any(|c| {
matches!(
c,
ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
)
});
if !force
&& mlx_dir_has_weights(&model_dir)
&& (is_diffusers || config_path.exists())
{
ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
return Ok(model_dir);
}
if !force {
if let Some(snapshot_dir) =
latest_huggingface_repo_snapshot(hf_repo).filter(|d| mlx_dir_has_weights(d))
{
ensure_auxiliary_mlx_files(&schema.name, hf_repo, &snapshot_dir).await?;
info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
return Ok(snapshot_dir);
}
}
std::fs::create_dir_all(&model_dir)?;
info!(model = %schema.name, repo = %hf_repo, "downloading MLX model");
if is_diffusers {
download_repo_snapshot(hf_repo, &model_dir, sink).await?;
ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
if !mlx_dir_has_weights(&model_dir) {
return Err(InferenceError::DownloadFailed(format!(
"{hf_repo}: snapshot fetched but no component weights found"
)));
}
info!(model = %schema.name, path = %model_dir.display(), "downloaded diffusers model");
return Ok(model_dir);
}
emit_file(sink, "config", 0, schema.size_mb());
download_file(hf_repo, "config.json", &config_path).await?;
download_tokenizer_assets(hf_repo, &model_dir, sink).await;
let tok_config_path = model_dir.join("tokenizer_config.json");
if !crate::download::cache_file_usable(&tok_config_path) {
let _ = download_file(hf_repo, "tokenizer_config.json", &tok_config_path).await;
}
if let Some(ref wf) = hf_weight_file {
let wf_path = model_dir.join(wf);
if !crate::download::cache_file_usable(&wf_path) {
emit_file(sink, "model weights", 0, schema.size_mb());
download_file(hf_repo, wf, &wf_path).await?;
}
} else {
let single = model_dir.join("model.safetensors");
if !crate::download::cache_file_usable(&single) {
emit_file(sink, "model weights", 0, schema.size_mb());
match download_file(hf_repo, "model.safetensors", &single).await {
Ok(()) => {}
Err(_) => {
let index_path = model_dir.join("model.safetensors.index.json");
download_file(hf_repo, "model.safetensors.index.json", &index_path)
.await?;
let index_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&index_path)?)
.map_err(|e| {
InferenceError::InferenceFailed(format!(
"parse index: {e}"
))
})?;
if let Some(weight_map) =
index_json.get("weight_map").and_then(|m| m.as_object())
{
let mut files: std::collections::HashSet<String> =
std::collections::HashSet::new();
for filename in weight_map.values() {
if let Some(f) = filename.as_str() {
files.insert(f.to_string());
}
}
let shard_total = files.len() as u32;
for (i, file) in files.iter().enumerate() {
let dest = model_dir.join(file);
if !crate::download::cache_file_usable(&dest) {
info!(file = %file, "downloading weight shard");
sink.emit(DownloadEvent::FileStarted {
filename: format!("weights part {}", i + 1),
index: (i + 1) as u32,
total_files: shard_total,
size_mb: 0,
});
download_file(hf_repo, file, &dest).await?;
sink.emit(DownloadEvent::FileCompleted {
filename: format!("weights part {}", i + 1),
});
}
}
}
}
}
}
}
ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
let missing = missing_weight_shards(&model_dir);
if !missing.is_empty() {
return Err(InferenceError::DownloadFailed(format!(
"{}: pull finished but {} weight shard(s) are still missing: {}. \
The download was interrupted; re-run the pull to resume it.",
schema.name,
missing.len(),
missing.join(", ")
)));
}
if !mlx_dir_has_weights(&model_dir) {
return Err(InferenceError::DownloadFailed(format!(
"{}: pull finished but no usable weights are present under {}",
schema.name,
model_dir.display()
)));
}
Ok(model_dir)
}
_ => Err(InferenceError::InferenceFailed(format!(
"model {} is not local",
id
))),
}
}
pub fn remove_local(&mut self, id: &str) -> Result<(), InferenceError> {
let schema = self
.get(id)
.or_else(|| self.find_by_name(id))
.ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
let name = schema.name.clone();
let id = schema.id.clone();
let mut targets: Vec<std::path::PathBuf> = vec![self.models_dir.join(&name)];
match &schema.source {
ModelSource::Mlx { hf_repo, .. } => {
targets.push(huggingface_repo_dir(hf_repo));
}
ModelSource::Local {
hf_repo,
tokenizer_repo,
..
} => {
targets.push(huggingface_repo_dir(hf_repo));
targets.push(huggingface_repo_dir(tokenizer_repo));
}
_ => {}
}
let mut first_err: Option<std::io::Error> = None;
for dir in &targets {
if dir.exists() {
match std::fs::remove_dir_all(dir) {
Ok(()) => info!(model = %name, dir = %dir.display(), "removed model artifacts"),
Err(e) => {
tracing::warn!(model = %name, dir = %dir.display(), error = %e, "failed to remove model artifacts");
if first_err.is_none() {
first_err = Some(e);
}
}
}
}
}
if let Some(m) = self.models.get_mut(&id) {
m.available = false;
}
match first_err {
Some(e) => Err(e.into()),
None => Ok(()),
}
}
pub fn refresh_availability(&mut self) {
let models_dir = self.models_dir.clone();
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
let mlx_vlm_cli_present = crate::backend::mlx_vlm_cli::is_available();
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
#[allow(unused_variables)]
let mlx_vlm_cli_present = false;
let parslee_oauth_available = car_auth::access_token_is_available();
if !parslee_oauth_available {
crate::openrouter::clear_gateway_unconfigured();
}
let mut credential_envs: std::collections::BTreeSet<String> = Default::default();
let mut needs_openrouter = false;
for m in self.models.values() {
match &m.source {
ModelSource::RemoteApi {
protocol: crate::schema::ApiProtocol::OpenRouter,
..
} => needs_openrouter = true,
ModelSource::RemoteApi { api_key_env, .. } => {
credential_envs.insert(api_key_env.clone());
}
ModelSource::Proprietary { auth, .. } => match auth {
ProprietaryAuth::ApiKeyEnv { env_var }
| ProprietaryAuth::BearerTokenEnv { env_var } => {
credential_envs.insert(env_var.clone());
}
ProprietaryAuth::OAuth2Pkce { .. } => {}
},
_ => {}
}
}
let credential_available: std::collections::HashMap<String, bool> = credential_envs
.into_iter()
.map(|env| {
let ok = car_secrets::resolve_env_or_keychain(&env).is_some();
(env, ok)
})
.collect();
let openrouter_available =
needs_openrouter && crate::openrouter::credential_source().is_some();
for m in self.models.values_mut() {
match &m.source {
ModelSource::Mlx { hf_repo, .. } => {
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
{
let needs_mlx_vlm = m.tags.iter().any(|t| t == "requires-mlx-vlm");
m.available = if needs_mlx_vlm {
mlx_vlm_cli_present
} else if m.tags.contains(&"speech".to_string()) {
speech_mlx_available()
} else {
let mlx_dir = models_dir.join(&m.name);
mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty()
};
}
#[cfg(not(all(
target_os = "macos",
target_arch = "aarch64",
not(car_skip_mlx)
)))]
{
let _ = hf_repo; m.available = false;
}
}
ModelSource::Local { .. } => {
let local_path = models_dir.join(&m.name).join("model.gguf");
m.available = local_path.exists();
}
ModelSource::WhisperCpp { .. } => {
m.available = true;
}
ModelSource::WindowsSpeech {} => {
#[cfg(target_os = "windows")]
{
m.available = true;
}
#[cfg(not(target_os = "windows"))]
{
m.available = false;
}
}
ModelSource::RemoteApi {
protocol: crate::schema::ApiProtocol::OpenRouter,
..
} => {
m.available = openrouter_available;
}
ModelSource::RemoteApi { api_key_env, .. } => {
m.available = credential_available
.get(api_key_env)
.copied()
.unwrap_or(false);
}
ModelSource::Ollama { .. } => {
m.available = true;
}
ModelSource::VllmMlx { .. } => {
m.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || m.available;
}
ModelSource::Proprietary { provider, auth, .. } => {
m.available = proprietary_auth_available(
&m.id,
&m.provider,
provider,
auth,
parslee_oauth_available,
Some(&credential_available),
);
}
ModelSource::AppleFoundationModels { .. } => {
#[cfg(any(
all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
all(target_os = "ios", target_arch = "aarch64")
))]
{
m.available = crate::backend::foundation_models::is_available();
}
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
all(target_os = "ios", target_arch = "aarch64")
)))]
{
m.available = false;
}
}
ModelSource::Delegated { .. } => {
m.available = crate::runner::current_inference_runner().is_some();
}
}
m.weights_ready = if m.is_mlx() {
mlx_dir_has_weights(&models_dir.join(&m.name))
} else if let ModelSource::WhisperCpp { model } = &m.source {
car_whisper::model_cached(model)
} else if matches!(m.source, ModelSource::WindowsSpeech {}) {
true
} else if m.is_local() {
models_dir.join(&m.name).join("model.gguf").exists()
} else {
true
};
}
}
pub fn save_user_config(&self) -> Result<(), InferenceError> {
let mut user_models: Vec<ModelSchema> = self
.user_config_ids
.iter()
.filter_map(|id| self.models.get(id))
.cloned()
.map(|mut model| {
model.mark_user_registered();
model
})
.collect();
user_models.sort_by(|a, b| a.id.cmp(&b.id));
let json = serde_json::to_string_pretty(&user_models)
.map_err(|e| InferenceError::InferenceFailed(format!("serialize: {e}")))?;
std::fs::write(&self.user_config_path, json)?;
Ok(())
}
pub fn load_user_config(&mut self) -> Result<(), InferenceError> {
if !self.user_config_path.exists() {
return Ok(());
}
let json = std::fs::read_to_string(&self.user_config_path)?;
let models: Vec<ModelSchema> = serde_json::from_str(&json)
.map_err(|e| InferenceError::InferenceFailed(format!("parse models.json: {e}")))?;
for m in models {
self.register_user_model(m);
}
Ok(())
}
pub fn models_dir(&self) -> &Path {
&self.models_dir
}
pub fn ready_without_download(&self, id: &str) -> Option<bool> {
let schema = self.get(id).or_else(|| self.find_by_name(id))?;
Some(match &schema.source {
ModelSource::Local { .. } => {
let model_dir = self.models_dir.join(&schema.name);
crate::download::cache_file_usable(&model_dir.join("model.gguf"))
&& crate::download::cache_file_usable(&model_dir.join("tokenizer.json"))
}
ModelSource::Mlx { hf_repo, .. } => {
let managed_dir = self.models_dir.join(&schema.name);
let managed_ready =
crate::download::cache_file_usable(&managed_dir.join("config.json"))
&& crate::download::cache_file_usable(&managed_dir.join("tokenizer.json"))
&& mlx_dir_has_weights(&managed_dir)
&& mlx_auxiliary_ready_without_download(&schema.name, &managed_dir);
let snapshot_ready = latest_huggingface_repo_snapshot(hf_repo)
.filter(|dir| {
crate::download::cache_file_usable(&dir.join("config.json"))
&& crate::download::cache_file_usable(&dir.join("tokenizer.json"))
&& mlx_dir_has_weights(dir)
&& mlx_auxiliary_ready_without_download(&schema.name, dir)
})
.is_some();
managed_ready || snapshot_ready
}
ModelSource::WindowsSpeech {} => true, ModelSource::WhisperCpp { model } => {
car_whisper::model_cached(model)
}
ModelSource::RemoteApi { .. }
| ModelSource::Ollama { .. }
| ModelSource::VllmMlx { .. }
| ModelSource::AppleFoundationModels { .. }
| ModelSource::Proprietary { .. }
| ModelSource::Delegated { .. } => true,
})
}
fn load_builtin_catalog(&mut self) {
for schema in builtin_catalog() {
self.register_project_model(schema);
}
}
}
fn synthesize_local_schema(name: &str, dir: &Path) -> Option<ModelSchema> {
let lower = name.to_ascii_lowercase();
const NON_TEXT_HINTS: &[&str] = &[
"vad",
"whisper",
"parakeet",
"kokoro",
"tts",
"stt",
"flux",
"ltx",
"yume",
"sd-",
"stable-diffusion",
"wan",
"mochi",
"sana",
"diffusion",
];
if NON_TEXT_HINTS.iter().any(|h| lower.contains(h)) {
return None;
}
let capabilities: Vec<ModelCapability> =
if lower.contains("embedding") || lower.contains("embed") {
vec![ModelCapability::Embed]
} else if lower.contains("reranker") || lower.contains("rerank") {
vec![ModelCapability::Rerank]
} else {
vec![
ModelCapability::Generate,
ModelCapability::Code,
ModelCapability::Reasoning,
]
};
let config_path = dir.join("config.json");
let has_safetensors =
dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists();
let (source, context_length, quantization) = if config_path.exists() && has_safetensors {
let cfg: serde_json::Value = std::fs::read_to_string(&config_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())?;
let model_type = cfg
.get("model_type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_ascii_lowercase();
const KNOWN_LLM_TYPES: &[&str] = &[
"qwen",
"qwen2",
"qwen3",
"qwen3_moe",
"llama",
"mistral",
"mixtral",
"gemma",
"gemma2",
"gemma3",
"gemma4_unified",
"gemma4_unified_text",
"phi",
"phi3",
"phimoe",
"starcoder2",
"deepseek",
"deepseek_v2",
"internlm2",
"cohere",
"olmo",
];
if !KNOWN_LLM_TYPES.iter().any(|t| model_type == *t) {
return None;
}
let ctx = cfg
.get("max_position_embeddings")
.and_then(|v| v.as_u64())
.unwrap_or(32_768) as usize;
let quant = cfg
.get("quantization")
.and_then(|q| q.get("bits"))
.and_then(|b| b.as_u64())
.map(|bits| format!("{bits}-bit"));
(
serde_json::json!({ "type": "mlx", "hf_repo": "" }),
ctx,
quant,
)
} else {
let gguf = std::fs::read_dir(dir).ok().and_then(|rd| {
rd.flatten().map(|e| e.path()).find(|p| {
p.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("gguf"))
})
})?;
let filename = gguf
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("model.gguf")
.to_string();
(
serde_json::json!({
"type": "local",
"hf_repo": "",
"hf_filename": filename,
"tokenizer_repo": "",
}),
4_096,
None,
)
};
let id = format!("local/{}", lower.replace(['/', ' '], "-"));
serde_json::from_value(serde_json::json!({
"id": id,
"name": name,
"provider": "local",
"family": "local",
"capabilities": capabilities,
"context_length": context_length,
"quantization": quantization,
"source": source,
"tags": ["auto-discovered"],
"trust_tier": "community",
}))
.ok()
}
#[allow(dead_code)] fn speech_mlx_available() -> bool {
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
{
true
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
{
let runtime_root = speech_runtime_root();
runtime_root
.join("bin")
.join("mlx_audio.stt.generate")
.exists()
|| runtime_root
.join("bin")
.join("mlx_audio.tts.generate")
.exists()
}
}
#[allow(dead_code)] fn speech_runtime_root() -> PathBuf {
if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
if !path.trim().is_empty() {
return PathBuf::from(path);
}
}
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".car")
.join("speech-runtime")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub provider: String,
pub capabilities: Vec<ModelCapability>,
pub param_count: String,
pub size_mb: u64,
pub context_length: usize,
pub available: bool,
pub is_local: bool,
#[serde(default)]
pub max_output_tokens: Option<usize>,
#[serde(default)]
pub public_benchmarks: Vec<crate::schema::BenchmarkScore>,
#[serde(default)]
pub cost: crate::schema::CostModel,
}
impl From<&ModelSchema> for ModelInfo {
fn from(s: &ModelSchema) -> Self {
ModelInfo {
id: s.id.clone(),
name: s.name.clone(),
provider: s.provider.clone(),
capabilities: s.capabilities.clone(),
param_count: s.param_count.clone(),
size_mb: s.size_mb(),
context_length: s.context_length,
available: s.available_now(),
is_local: s.is_local(),
max_output_tokens: s.max_output_tokens,
public_benchmarks: s.public_benchmarks.clone(),
cost: s.cost.clone(),
}
}
}
fn emit_file(sink: &ProgressSink, name: &str, index: u32, size_mb: u64) {
sink.emit(DownloadEvent::FileStarted {
filename: name.to_string(),
index,
total_files: 0,
size_mb,
});
}
async fn download_repo_snapshot(
repo: &str,
model_dir: &Path,
sink: &ProgressSink,
) -> Result<(), InferenceError> {
#[derive(serde::Deserialize)]
struct RepoInfo {
siblings: Vec<Sibling>,
}
#[derive(serde::Deserialize)]
struct Sibling {
rfilename: String,
}
let url = format!("https://huggingface.co/api/models/{repo}");
let info: RepoInfo = crate::tls_client::model_download_client()
.get(&url)
.send()
.await
.map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
.error_for_status()
.map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
.json()
.await
.map_err(|e| InferenceError::DownloadFailed(format!("parse {repo} file list: {e}")))?;
let files: Vec<String> = info
.siblings
.into_iter()
.map(|s| s.rfilename)
.filter(|f| !f.starts_with('.') && !f.to_ascii_lowercase().ends_with(".md"))
.collect();
if files.is_empty() {
return Err(InferenceError::DownloadFailed(format!(
"{repo}: repo lists no downloadable files"
)));
}
let total = files.len() as u32;
for (i, fname) in files.iter().enumerate() {
let dest = model_dir.join(fname);
if crate::download::cache_file_usable(&dest) {
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
sink.emit(DownloadEvent::FileStarted {
filename: fname.clone(),
index: (i + 1) as u32,
total_files: total,
size_mb: 0,
});
download_file(repo, fname, &dest).await?;
sink.emit(DownloadEvent::FileCompleted {
filename: fname.clone(),
});
}
Ok(())
}
const TOKENIZER_FILENAMES: &[&str] = &[
"tokenizer.json",
"vocab.json",
"merges.txt",
"tokenizer.model",
"tokenizer.vocab",
"vocab.txt",
];
async fn download_tokenizer_assets(hf_repo: &str, model_dir: &Path, sink: &ProgressSink) {
if TOKENIZER_FILENAMES
.iter()
.any(|f| crate::download::cache_file_usable(&model_dir.join(f)))
{
return;
}
emit_file(sink, "tokenizer", 0, 0);
let mut fetched: Vec<&str> = Vec::new();
for name in TOKENIZER_FILENAMES {
let dest = model_dir.join(name);
if crate::download::cache_file_usable(&dest) {
continue;
}
if download_file(hf_repo, name, &dest).await.is_ok() {
fetched.push(name);
}
}
if fetched.is_empty() {
tracing::debug!(
repo = %hf_repo,
"no tokenizer assets in this repo; continuing (the backend may not need one)"
);
} else {
tracing::debug!(repo = %hf_repo, files = ?fetched, "fetched tokenizer assets");
}
}
async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
let api = hf_hub::api::tokio::Api::new()
.map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
let repo = api.model(repo.to_string());
let path = repo
.get(filename)
.await
.map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
if dest.exists() {
return Ok(());
}
#[cfg(unix)]
{
if std::os::unix::fs::symlink(&path, dest).is_ok() {
return Ok(());
}
}
std::fs::copy(&path, dest)
.map_err(|e| InferenceError::DownloadFailed(format!("copy to {}: {e}", dest.display())))?;
Ok(())
}
async fn ensure_auxiliary_mlx_files(
model_name: &str,
hf_repo: &str,
model_dir: &Path,
) -> Result<(), InferenceError> {
if hf_repo == "mlx-community/Flux-1.lite-8B-MLX-Q4" || model_name == "Flux-1.lite-8B-MLX-Q4" {
let t5_tokenizer_path = model_dir.join("tokenizer_2").join("tokenizer.json");
if !t5_tokenizer_path.exists() {
std::fs::create_dir_all(t5_tokenizer_path.parent().ok_or_else(|| {
InferenceError::InferenceFailed("invalid tokenizer path".into())
})?)?;
info!(
path = %t5_tokenizer_path.display(),
"downloading missing Flux tokenizer_2/tokenizer.json from base model"
);
download_file(
"Freepik/flux.1-lite-8B",
"tokenizer_2/tokenizer.json",
&t5_tokenizer_path,
)
.await?;
}
}
Ok(())
}
fn mlx_auxiliary_ready_without_download(model_name: &str, model_dir: &Path) -> bool {
if model_name == "Flux-1.lite-8B-MLX-Q4" {
return crate::download::cache_file_usable(
&model_dir.join("tokenizer_2").join("tokenizer.json"),
);
}
true
}
pub(crate) fn mlx_dir_has_weights(dir: &Path) -> bool {
let index = dir.join("model.safetensors.index.json");
if index.is_file() {
if let Some(required) = sharded_weight_files(&index) {
return !required.is_empty() && required.iter().all(|shard| dir.join(shard).exists());
}
}
mlx_dir_has_weights_depth(dir, 0)
}
pub(crate) fn missing_weight_shards(dir: &Path) -> Vec<String> {
let index = dir.join("model.safetensors.index.json");
if !index.is_file() {
return Vec::new();
}
let Some(required) = sharded_weight_files(&index) else {
return Vec::new();
};
required
.into_iter()
.filter(|shard| !dir.join(shard).exists())
.collect()
}
fn sharded_weight_files(index: &Path) -> Option<Vec<String>> {
let raw = std::fs::read_to_string(index).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
let map = parsed.get("weight_map")?.as_object()?;
let mut files: Vec<String> = map
.values()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
files.sort();
files.dedup();
Some(files)
}
fn mlx_dir_has_weights_depth(dir: &Path, depth: usize) -> bool {
if depth > 4 {
return false;
}
let Ok(rd) = std::fs::read_dir(dir) else {
return false;
};
rd.flatten().any(|e| {
let p = e.path();
let is_symlink = std::fs::symlink_metadata(&p)
.map(|m| m.file_type().is_symlink())
.unwrap_or(true);
if p.is_dir() {
!is_symlink && mlx_dir_has_weights_depth(&p, depth + 1)
} else {
p.extension().and_then(|x| x.to_str()) == Some("safetensors")
&& crate::download::cache_file_usable(&p)
}
})
}
#[allow(dead_code)] fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
latest_huggingface_repo_snapshot(repo_id).is_some()
}
pub(crate) fn huggingface_cache_root() -> PathBuf {
std::env::var("HF_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".cache")
.join("huggingface")
})
.join("hub")
}
fn huggingface_repo_dir(repo_id: &str) -> PathBuf {
huggingface_cache_root().join(format!("models--{}", repo_id.replace('/', "--")))
}
fn resolve_huggingface_ref_snapshot(repo_dir: &Path, name: &str) -> Option<PathBuf> {
let sha = std::fs::read_to_string(repo_dir.join("refs").join(name))
.ok()?
.trim()
.to_string();
if sha.is_empty() {
return None;
}
let snapshot = repo_dir.join("snapshots").join(sha);
if snapshot_looks_ready(&snapshot) {
Some(snapshot)
} else {
None
}
}
fn latest_huggingface_repo_snapshot(repo_id: &str) -> Option<PathBuf> {
let repo_dir = huggingface_repo_dir(repo_id);
if let Some(snapshot) = resolve_huggingface_ref_snapshot(&repo_dir, "main") {
return Some(snapshot);
}
let snapshots = repo_dir.join("snapshots");
let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(snapshots)
.ok()?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_dir() && snapshot_looks_ready(p))
.map(|path| {
let modified = path
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
(modified, path)
})
.collect();
candidates.sort();
candidates.pop().map(|(_, path)| path)
}
fn snapshot_looks_ready(path: &Path) -> bool {
if path.join("config.json").exists() || path.join("model_index.json").exists() {
return true;
}
snapshot_contains_ext(path, "safetensors")
}
fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
let Ok(entries) = std::fs::read_dir(root) else {
return false;
};
entries.filter_map(Result::ok).any(|entry| {
let path = entry.path();
if path.is_dir() {
snapshot_contains_ext(&path, ext)
} else {
let ext_matches = path
.extension()
.and_then(|value| value.to_str())
.map(|value| value.eq_ignore_ascii_case(ext))
.unwrap_or(false);
ext_matches && crate::download::cache_file_usable(&path)
}
})
}
const BUILTIN_CATALOG_JSON: &str = include_str!("builtin_catalog.json");
static BUILTIN_CATALOG: std::sync::LazyLock<Vec<ModelSchema>> = std::sync::LazyLock::new(|| {
serde_json::from_str(BUILTIN_CATALOG_JSON)
.expect("builtin_catalog.json failed to parse — fix the JSON, not this code")
});
pub(crate) fn builtin_catalog() -> Vec<ModelSchema> {
let mut catalog = BUILTIN_CATALOG.clone();
catalog.extend(crate::openrouter::builtin_schemas());
catalog
}
#[cfg(test)]
mod tests {
#[test]
fn a_sharded_model_missing_one_shard_is_not_installed() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("model.safetensors.index.json"),
r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
"b":"model-00002-of-00002.safetensors"}}"#,
)
.unwrap();
std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
assert!(
!mlx_dir_has_weights(dir),
"a missing shard must read as not-installed, or pull silently no-ops"
);
std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
assert!(
mlx_dir_has_weights(dir),
"a complete shard set must read as installed"
);
}
#[test]
fn missing_shards_are_reported_by_name() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("model.safetensors.index.json"),
r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
"b":"model-00002-of-00002.safetensors"}}"#,
)
.unwrap();
std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
assert_eq!(
missing_weight_shards(dir),
vec!["model-00001-of-00002.safetensors".to_string()],
"the absent shard must be named, not just counted"
);
std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
assert!(
missing_weight_shards(dir).is_empty(),
"a complete shard set must report nothing missing"
);
}
#[test]
fn missing_shards_is_empty_without_an_index() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
assert!(missing_weight_shards(tmp.path()).is_empty());
let bad = tempfile::tempdir().unwrap();
std::fs::write(bad.path().join("model.safetensors.index.json"), b"not json").unwrap();
assert!(missing_weight_shards(bad.path()).is_empty());
}
#[test]
fn a_single_file_model_still_counts_without_an_index() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
assert!(mlx_dir_has_weights(tmp.path()));
}
#[test]
fn an_unparseable_index_falls_back_instead_of_failing_closed() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("model.safetensors.index.json"),
b"{not-json",
)
.unwrap();
std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
assert!(
mlx_dir_has_weights(tmp.path()),
"an unreadable index should fall back to the weights walk"
);
}
use super::*;
use tempfile::TempDir;
#[test]
fn mlx_dir_has_weights_detects_completeness() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("config.json"), "{}").unwrap();
std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
assert!(
!mlx_dir_has_weights(dir),
"config-only stub must not count as installed"
);
std::fs::write(dir.join("model.safetensors.index.json"), "{}").unwrap();
assert!(!mlx_dir_has_weights(dir), "index.json alone is not weights");
std::fs::write(dir.join("model.safetensors"), b"\x00\x01\x02").unwrap();
assert!(mlx_dir_has_weights(dir));
}
#[test]
fn mlx_dir_has_weights_handles_sharded_and_dangling_symlinks() {
let sharded = TempDir::new().unwrap();
std::fs::write(sharded.path().join("config.json"), "{}").unwrap();
std::fs::write(
sharded.path().join("model-00001-of-00002.safetensors"),
b"\x00",
)
.unwrap();
assert!(mlx_dir_has_weights(sharded.path()), "sharded shard counts");
#[cfg(unix)]
{
let dangling = TempDir::new().unwrap();
std::fs::write(dangling.path().join("config.json"), "{}").unwrap();
std::os::unix::fs::symlink(
dangling.path().join("does-not-exist"),
dangling.path().join("model.safetensors"),
)
.unwrap();
assert!(
!mlx_dir_has_weights(dangling.path()),
"dangling weight symlink must count as absent"
);
}
}
fn test_registry() -> (UnifiedRegistry, TempDir) {
let tmp = TempDir::new().unwrap();
let reg = UnifiedRegistry::new(tmp.path().join("models"));
(reg, tmp)
}
fn test_generate_schema(id: &str, name: &str, source: ModelSource) -> ModelSchema {
ModelSchema {
id: id.into(),
name: name.into(),
provider: "local".into(),
family: "qwen3".into(),
version: "test".into(),
capabilities: vec![ModelCapability::Generate],
context_length: 4096,
max_output_tokens: None,
param_count: String::new(),
quantization: None,
performance: PerformanceEnvelope::default(),
cost: CostModel::default(),
source,
tags: vec![],
supported_params: vec![],
public_benchmarks: vec![],
trust_tier: crate::schema::TrustTier::Curated,
deprecated: false,
available: false,
weights_ready: false,
}
}
#[test]
fn a_gateway_that_reports_no_upstream_stops_being_advertised() {
let _guard = crate::openrouter::test_environment_scope();
crate::openrouter::clear_gateway_unconfigured();
let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
.into_iter()
.filter(|s| crate::openrouter::is_curated_managed_gateway_alias(&s.id))
.collect();
assert!(
!managed.is_empty(),
"precondition: the curated catalog must still carry managed aliases"
);
let availability_of = |schema: &ModelSchema| match &schema.source {
ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
&schema.id,
&schema.provider,
provider,
auth,
true,
None,
),
other => panic!("managed aliases must be Proprietary, got {other:?}"),
};
assert!(
managed.iter().all(availability_of),
"precondition: an authenticated session advertises these today"
);
crate::openrouter::note_gateway_unconfigured();
assert!(
managed.iter().all(|s| !availability_of(s)),
"after the gateway says it has no OpenRouter upstream, every alias in \
the namespace must report unavailable — that claim is what cost the \
benchmark sweep in #786"
);
crate::openrouter::clear_gateway_unconfigured();
assert!(
managed.iter().all(availability_of),
"the suppression must be recoverable, not a one-way latch"
);
}
#[test]
fn refresh_availability_probes_each_credential_once_not_per_model() {
let tmp = TempDir::new().unwrap();
let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
for i in 0..25 {
let mut schema = test_generate_schema(
&format!("openrouter/model-{i}"),
&format!("model-{i}"),
ModelSource::RemoteApi {
protocol: crate::schema::ApiProtocol::OpenRouter,
endpoint: "https://openrouter.ai/api/v1".into(),
api_key_env: "OPENROUTER_API_KEY".into(),
api_key_envs: vec![],
api_version: None,
},
);
schema.provider = "openrouter".into();
registry.register(schema);
}
crate::openrouter::reset_credential_source_call_count();
registry.refresh_availability();
let calls = crate::openrouter::credential_source_call_count();
assert_eq!(
calls, 1,
"refresh_availability probed the OpenRouter credential {calls} times for 25 models; \
it must resolve each distinct credential once per refresh, not once per model"
);
}
#[test]
fn user_config_load_and_save_force_community_trust() {
let tmp = TempDir::new().unwrap();
let models_dir = tmp.path().join("models");
let config_path = tmp.path().join("models.json");
let schema = test_generate_schema(
"user/test-model",
"user-test-model",
ModelSource::RemoteApi {
endpoint: "https://attacker.invalid/v1/chat/completions".into(),
api_key_env: "CAR_USER_MODEL_TEST_KEY".into(),
api_key_envs: vec![],
api_version: None,
protocol: crate::schema::ApiProtocol::OpenAiCompat,
},
);
let mut omitted_tier = serde_json::to_value(schema.clone()).unwrap();
omitted_tier.as_object_mut().unwrap().remove("trust_tier");
std::fs::write(
&config_path,
serde_json::to_vec_pretty(&vec![omitted_tier]).unwrap(),
)
.unwrap();
let mut loaded = UnifiedRegistry::new_empty(models_dir.clone());
loaded.load_user_config().unwrap();
assert_eq!(
loaded.get("user/test-model").unwrap().trust_tier,
crate::schema::TrustTier::Community
);
let mut persisted = UnifiedRegistry::new_empty(models_dir);
persisted.register_user_model(schema);
persisted.save_user_config().unwrap();
let saved: Vec<ModelSchema> =
serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
}
#[test]
fn persisted_user_model_cannot_shadow_managed_openrouter_alias() {
let tmp = TempDir::new().unwrap();
let models_dir = tmp.path().join("models");
let config_path = tmp.path().join("models.json");
let mut shadow = crate::openrouter::curated_schemas()
.into_iter()
.find(|schema| schema.id == "parslee/openrouter/frontier-general")
.unwrap();
shadow.provider = "attacker".into();
std::fs::write(
&config_path,
serde_json::to_vec_pretty(&vec![shadow]).unwrap(),
)
.unwrap();
let registry = UnifiedRegistry::new(models_dir);
let actual = registry
.get("parslee/openrouter/frontier-general")
.expect("compiled managed alias must remain present");
assert_eq!(actual.provider, "parslee");
assert_eq!(
crate::openrouter::canonical_managed_gateway_selector(actual),
Some("parslee/openrouter/frontier-general")
);
}
#[test]
fn user_config_persistence_excludes_signed_rows_and_keeps_builtin_tagged_user_rows() {
let tmp = TempDir::new().unwrap();
let models_dir = tmp.path().join("models");
let config_path = tmp.path().join("models.json");
let signed = test_generate_schema(
"signed/catalog-only",
"signed-catalog-only",
ModelSource::RemoteApi {
endpoint: "https://catalog.example/v1".into(),
api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
api_key_envs: vec![],
api_version: None,
protocol: crate::schema::ApiProtocol::OpenAiCompat,
},
);
assert!(!signed.tags.iter().any(|tag| tag == "builtin"));
let (verified, public_key) = crate::catalog::signed_test_catalog(
crate::catalog::CatalogDoc {
version: 81,
models: vec![signed],
},
81,
);
crate::catalog::save_verified(&crate::catalog::cache_path(&models_dir), &verified).unwrap();
let mut registry = UnifiedRegistry::new_with_catalog_public_key(
models_dir.clone(),
Some(public_key.as_str()),
);
let mut user = test_generate_schema(
"user/builtin-tagged",
"user-builtin-tagged",
ModelSource::RemoteApi {
endpoint: "https://user.example/v1".into(),
api_key_env: "USER_MODEL_TEST_KEY".into(),
api_key_envs: vec![],
api_version: None,
protocol: crate::schema::ApiProtocol::OpenAiCompat,
},
);
user.tags.push("builtin".into());
registry.register_user_model(user);
registry.save_user_config().unwrap();
let saved: Vec<ModelSchema> =
serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
assert_eq!(
saved
.iter()
.map(|model| model.id.as_str())
.collect::<Vec<_>>(),
vec!["user/builtin-tagged"],
"models.json must contain only explicitly user-registered rows"
);
assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
let restarted =
UnifiedRegistry::new_with_catalog_public_key(models_dir, Some(public_key.as_str()));
assert_eq!(
restarted.get("signed/catalog-only").unwrap().trust_tier,
crate::schema::TrustTier::Curated,
"user persistence must not demote an unrelated signed catalog row"
);
assert_eq!(
restarted.get("user/builtin-tagged").unwrap().trust_tier,
crate::schema::TrustTier::Community
);
}
#[test]
fn empty_user_config_save_clears_stale_rows() {
let tmp = TempDir::new().unwrap();
let models_dir = tmp.path().join("models");
let config_path = tmp.path().join("models.json");
let stale = test_generate_schema(
"user/stale",
"stale",
ModelSource::RemoteApi {
endpoint: "https://stale.example/v1".into(),
api_key_env: "STALE_USER_MODEL_TEST_KEY".into(),
api_key_envs: vec![],
api_version: None,
protocol: crate::schema::ApiProtocol::OpenAiCompat,
},
);
std::fs::write(
&config_path,
serde_json::to_vec_pretty(&vec![stale]).unwrap(),
)
.unwrap();
UnifiedRegistry::new_empty(models_dir)
.save_user_config()
.unwrap();
let saved: Vec<ModelSchema> =
serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
assert!(
saved.is_empty(),
"saving an empty user set must overwrite stale models.json rows"
);
}
#[test]
fn unregister_then_save_removes_the_user_row_from_disk() {
let tmp = TempDir::new().unwrap();
let models_dir = tmp.path().join("models");
let config_path = tmp.path().join("models.json");
let mut registry = UnifiedRegistry::new_empty(models_dir);
registry.register_project_model(test_generate_schema(
"signed/not-user-removable",
"not-user-removable",
ModelSource::RemoteApi {
endpoint: "https://catalog.example/v1".into(),
api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
api_key_envs: vec![],
api_version: None,
protocol: crate::schema::ApiProtocol::OpenAiCompat,
},
));
assert!(
registry
.unregister_user_model("signed/not-user-removable")
.is_none(),
"the user boundary cannot unregister an untracked catalog row"
);
assert!(registry.get("signed/not-user-removable").is_some());
registry.register_user_model(test_generate_schema(
"user/removable",
"removable",
ModelSource::RemoteApi {
endpoint: "https://user.example/v1".into(),
api_key_env: "REMOVABLE_USER_MODEL_TEST_KEY".into(),
api_key_envs: vec![],
api_version: None,
protocol: crate::schema::ApiProtocol::OpenAiCompat,
},
));
registry.save_user_config().unwrap();
assert!(registry.unregister_user_model("user/removable").is_some());
registry.save_user_config().unwrap();
let saved: Vec<ModelSchema> =
serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
assert!(saved.is_empty());
}
#[test]
fn ready_without_download_is_strict_for_local_model_files() {
let tmp = TempDir::new().unwrap();
let models = tmp.path().join("models");
let mut reg = UnifiedRegistry::new_empty(models.clone());
reg.register(test_generate_schema(
"local/test",
"TestLocal",
ModelSource::Local {
hf_repo: "example/repo".into(),
hf_filename: "model.gguf".into(),
tokenizer_repo: "example/repo".into(),
},
));
assert_eq!(reg.ready_without_download("local/test"), Some(false));
let dir = models.join("TestLocal");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("model.gguf"), b"weights").unwrap();
assert_eq!(
reg.ready_without_download("local/test"),
Some(false),
"tokenizer is required too"
);
std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
assert_eq!(reg.ready_without_download("local/test"), Some(true));
}
#[test]
fn ready_without_download_rejects_mlx_config_only_stub() {
let tmp = TempDir::new().unwrap();
let models = tmp.path().join("models");
let mut reg = UnifiedRegistry::new_empty(models.clone());
reg.register(test_generate_schema(
"mlx/test",
"TestMlx",
ModelSource::Mlx {
hf_repo: "example/repo".into(),
hf_weight_file: None,
},
));
let dir = models.join("TestMlx");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("config.json"), "{}").unwrap();
std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
assert_eq!(
reg.ready_without_download("mlx/test"),
Some(false),
"config/tokenizer stubs must not start assistant inference"
);
std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
assert_eq!(reg.ready_without_download("mlx/test"), Some(true));
}
fn write_mlx_dir(root: &Path, name: &str, model_type: &str) {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("config.json"),
serde_json::json!({
"model_type": model_type,
"max_position_embeddings": 40_960,
"quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" },
})
.to_string(),
)
.unwrap();
std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
}
#[test]
fn synthesize_local_schema_classifies_by_name_and_arch() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_mlx_dir(root, "MyCustom-Qwen3-7B", "qwen3");
let gen = synthesize_local_schema("MyCustom-Qwen3-7B", &root.join("MyCustom-Qwen3-7B"))
.expect("text LLM should be recognized");
assert_eq!(
gen.capabilities,
vec![
ModelCapability::Generate,
ModelCapability::Code,
ModelCapability::Reasoning
]
);
assert_eq!(gen.context_length, 40_960);
assert_eq!(gen.provider, "local");
assert!(matches!(gen.source, ModelSource::Mlx { .. }));
write_mlx_dir(root, "Some-Embedding-0.6B", "qwen3");
let emb = synthesize_local_schema("Some-Embedding-0.6B", &root.join("Some-Embedding-0.6B"))
.expect("embedding model recognized");
assert_eq!(emb.capabilities, vec![ModelCapability::Embed]);
write_mlx_dir(root, "Mystery-Net", "some_unknown_arch");
assert!(synthesize_local_schema("Mystery-Net", &root.join("Mystery-Net")).is_none());
write_mlx_dir(root, "silero-vad-v6-mlx", "qwen3");
assert!(
synthesize_local_schema("silero-vad-v6-mlx", &root.join("silero-vad-v6-mlx")).is_none()
);
std::fs::create_dir_all(root.join("empty")).unwrap();
assert!(synthesize_local_schema("empty", &root.join("empty")).is_none());
}
#[test]
fn discovery_registers_uncatalogued_local_model() {
let tmp = TempDir::new().unwrap();
let models = tmp.path().join("models");
std::fs::create_dir_all(&models).unwrap();
write_mlx_dir(&models, "Totally-Custom-Llama-3B", "llama");
let reg = UnifiedRegistry::new(models);
let found = reg
.list()
.into_iter()
.find(|m| m.name == "Totally-Custom-Llama-3B");
assert!(
found.is_some(),
"uncatalogued on-disk model should be registered"
);
assert!(found.unwrap().tags.iter().any(|t| t == "auto-discovered"));
}
#[test]
fn refreshed_catalog_entry_replaces_builtin() {
let tmp = TempDir::new().unwrap();
let models_dir = tmp.path().join("models");
let builtin = builtin_catalog();
let mut overriding = builtin.first().expect("a built-in model").clone();
let target_id = overriding.id.clone();
overriding.name = "REPLACED-BY-CATALOG".into();
let (verified, public_key) = crate::catalog::signed_test_catalog(
crate::catalog::CatalogDoc {
version: 1,
models: vec![overriding],
},
51,
);
crate::catalog::save_verified(&crate::catalog::cache_path(&models_dir), &verified).unwrap();
let reg =
UnifiedRegistry::new_with_catalog_public_key(models_dir, Some(public_key.as_str()));
assert_eq!(
reg.get(&target_id).map(|m| m.name.as_str()),
Some("REPLACED-BY-CATALOG"),
"cache entry should replace the built-in of the same id"
);
}
#[test]
fn legacy_unsigned_catalog_cache_cannot_replace_builtin() {
let tmp = TempDir::new().unwrap();
let models_dir = tmp.path().join("models");
let builtin = builtin_catalog();
let original = builtin.first().expect("a built-in model");
let mut forged = original.clone();
forged.name = "FORGED-UNSIGNED-CATALOG".into();
let path = crate::catalog::cache_path(&models_dir);
std::fs::write(
&path,
serde_json::to_vec_pretty(&crate::catalog::CatalogDoc {
version: u64::MAX,
models: vec![forged],
})
.unwrap(),
)
.unwrap();
let reg = UnifiedRegistry::new(models_dir);
assert_eq!(
reg.get(&original.id).map(|model| model.name.as_str()),
Some(original.name.as_str()),
"legacy unsigned cache JSON must fail closed and preserve the built-in"
);
}
#[test]
fn tampered_signed_managed_row_preserves_builtin() {
let tmp = TempDir::new().unwrap();
let models_dir = tmp.path().join("models");
let original = builtin_catalog()
.into_iter()
.find(|model| model.id == "parslee/openrouter/frontier-general")
.expect("managed frontier alias");
let mut forged = original.clone();
forged.name = "SIGNED-THEN-TAMPERED-MANAGED".into();
let (verified, public_key) = crate::catalog::signed_test_catalog(
crate::catalog::CatalogDoc {
version: 9,
models: vec![forged],
},
52,
);
let path = crate::catalog::cache_path(&models_dir);
crate::catalog::save_verified(&path, &verified).unwrap();
let cache = std::fs::read_to_string(&path)
.unwrap()
.replace("SIGNED-THEN-TAMPERED-MANAGED", "ATTACKER-MUTATION");
std::fs::write(&path, cache).unwrap();
let reg =
UnifiedRegistry::new_with_catalog_public_key(models_dir, Some(public_key.as_str()));
assert_eq!(
reg.get(&original.id).map(|model| model.name.as_str()),
Some(original.name.as_str()),
"a tampered same-id managed row must fail verification and preserve the builtin"
);
}
#[test]
fn validly_signed_managed_id_with_wrong_name_is_not_a_trusted_gateway_alias() {
let tmp = TempDir::new().unwrap();
let models_dir = tmp.path().join("models");
let mut wrong_selector = builtin_catalog()
.into_iter()
.find(|model| model.id == "parslee/openrouter/frontier-general")
.expect("managed frontier alias");
wrong_selector.name = "attacker-controlled-upstream-selector".into();
let (verified, public_key) = crate::catalog::signed_test_catalog(
crate::catalog::CatalogDoc {
version: 82,
models: vec![wrong_selector],
},
82,
);
crate::catalog::save_verified(&crate::catalog::cache_path(&models_dir), &verified).unwrap();
let registry =
UnifiedRegistry::new_with_catalog_public_key(models_dir, Some(public_key.as_str()));
let loaded = registry
.get("parslee/openrouter/frontier-general")
.expect("validly signed row replaces the builtin");
assert_eq!(loaded.trust_tier, crate::schema::TrustTier::Curated);
assert_eq!(loaded.name, "attacker-controlled-upstream-selector");
assert!(
!crate::openrouter::is_managed_gateway_schema(loaded),
"signature trust does not allow a signed row to change the managed selector"
);
}
#[test]
fn builtin_catalog_loads() {
let (reg, _tmp) = test_registry();
let all = reg.list();
assert_eq!(all.len(), builtin_catalog().len());
}
#[test]
fn mlx_vlm_models_reflect_runtime_availability() {
let (reg, _tmp) = test_registry();
let mlx_vlm_models: Vec<&ModelSchema> = reg
.list()
.into_iter()
.filter(|m| m.tags.iter().any(|t| t == "requires-mlx-vlm"))
.collect();
assert!(
!mlx_vlm_models.is_empty(),
"catalog should contain at least one model tagged \
`requires-mlx-vlm` — otherwise this regression has \
nothing to guard"
);
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
let expected = crate::backend::mlx_vlm_cli::is_available();
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
let expected = false;
for m in mlx_vlm_models {
assert_eq!(
m.available, expected,
"model {} `available` field should reflect \
mlx_vlm CLI presence (expected {expected}, got {})",
m.id, m.available
);
}
}
#[test]
fn mlx_models_unavailable_on_non_mlx_targets() {
let (reg, _tmp) = test_registry();
let mlx_models: Vec<&ModelSchema> = reg
.list()
.into_iter()
.filter(|m| {
m.is_mlx()
&& !m.tags.iter().any(|t| t == "requires-mlx-vlm")
&& !m.tags.contains(&"speech".to_string())
})
.collect();
assert!(
!mlx_models.is_empty(),
"catalog should contain at least one plain MLX model — \
otherwise this F1 regression guard has nothing to guard"
);
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
{
let any_available = mlx_models.iter().any(|m| m.available);
assert!(
any_available,
"on macOS arm64 with MLX enabled, at least one plain MLX \
model with hf_repo should be available — none were"
);
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
{
for m in &mlx_models {
assert!(
!m.available,
"MLX model {} is marked available on a non-MLX target — \
the adaptive router will add it to fallback chains \
and dispatch will fail (Parslee-ai/car#231 §7.1)",
m.id
);
}
}
}
#[test]
fn builtin_catalog_json_parses() {
let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON)
.expect("builtin_catalog.json must be valid ModelSchema array");
assert!(
!catalog.is_empty(),
"embedded catalog has no entries — that's almost certainly wrong"
);
let mut seen = std::collections::HashSet::new();
for entry in &catalog {
assert!(
seen.insert(entry.id.clone()),
"duplicate id in builtin_catalog.json: {}",
entry.id
);
}
}
#[test]
fn in_process_qwen3_models_declare_tool_use() {
use crate::schema::ModelSource;
let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
let tool_sizes = ["qwen3-4b", "qwen3-8b", "qwen3-30b-a3b"];
let mut checked = 0;
for entry in &catalog {
let in_process = matches!(
entry.source,
ModelSource::Mlx { .. } | ModelSource::Local { .. }
);
if !in_process || !tool_sizes.iter().any(|s| entry.id.contains(s)) {
continue;
}
assert!(
entry.capabilities.contains(&ModelCapability::ToolUse),
"in-process Qwen3 model {} should advertise ToolUse — the local \
generate path renders/parses tool calls",
entry.id
);
checked += 1;
}
assert_eq!(
checked, 6,
"expected 6 in-process tool-capable Qwen3 entries (3 mlx + 3 gguf)"
);
}
#[test]
fn public_benchmarks_round_trip_through_model_info() {
use crate::schema::BenchmarkScore;
let (mut reg, _tmp) = test_registry();
let mut schema = reg
.find_by_name("Qwen3-4B")
.expect("catalog has Qwen3-4B")
.clone();
schema.id = "test/qwen3-4b-with-bench".into();
schema.public_benchmarks = vec![
BenchmarkScore {
name: "MMLU-Pro".into(),
score: 0.482,
harness: Some("5-shot CoT".into()),
source_url: Some("https://example.invalid/qwen3-4b-card".into()),
measured_at: Some("2025-08-12".into()),
},
BenchmarkScore {
name: "HumanEval".into(),
score: 0.713,
harness: Some("pass@1".into()),
source_url: None,
measured_at: None,
},
];
reg.register(schema);
let stored = reg
.get("test/qwen3-4b-with-bench")
.expect("registered model is retrievable");
let info = ModelInfo::from(stored);
assert_eq!(info.public_benchmarks.len(), 2);
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"public_benchmarks\""));
assert!(json.contains("\"MMLU-Pro\""));
assert!(json.contains("\"5-shot CoT\""));
let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.public_benchmarks.len(), 2);
assert_eq!(decoded.public_benchmarks[0].name, "MMLU-Pro");
assert_eq!(decoded.public_benchmarks[1].name, "HumanEval");
}
#[test]
fn public_benchmarks_default_to_empty_when_absent_in_json() {
let legacy_json = r#"{
"id": "legacy/test:1",
"name": "Legacy Test",
"provider": "test",
"family": "test",
"version": "",
"capabilities": ["generate"],
"context_length": 4096,
"param_count": "1B",
"quantization": null,
"performance": {},
"cost": {},
"source": { "type": "ollama", "model_tag": "legacy:1" },
"tags": [],
"supported_params": []
}"#;
let schema: ModelSchema = serde_json::from_str(legacy_json).unwrap();
assert!(schema.public_benchmarks.is_empty());
}
#[test]
fn find_by_name() {
let (reg, _tmp) = test_registry();
let m = reg.find_by_name("Qwen3-4B").unwrap();
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
assert_eq!(m.id, "mlx/qwen3-4b:4bit");
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
assert_eq!(m.id, "qwen/qwen3-4b:q4_k_m");
assert!(m.has_capability(ModelCapability::Code));
}
#[test]
fn query_by_capability() {
let (reg, _tmp) = test_registry();
let embed_models = reg.query_by_capability(ModelCapability::Embed);
assert_eq!(embed_models.len(), 2);
assert!(embed_models
.iter()
.any(|model| model.name == "Qwen3-Embedding-0.6B"));
assert!(embed_models
.iter()
.any(|model| model.name == "Qwen3-Embedding-0.6B-MLX"));
}
#[test]
fn query_with_filter() {
let (reg, _tmp) = test_registry();
let code_small = reg.query(&ModelFilter {
capabilities: vec![ModelCapability::Code],
max_size_mb: Some(3000),
local_only: true,
..Default::default()
});
assert_eq!(code_small.len(), 4);
}
#[test]
fn register_remote() {
let (mut reg, _tmp) = test_registry();
let initial_len = reg.list().len();
let initial_reasoning_len = reg
.query(&ModelFilter {
capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
..Default::default()
})
.len();
let remote = ModelSchema {
id: "anthropic/claude-sonnet-4-6:latest".into(),
name: "Claude Sonnet 4.6".into(),
provider: "anthropic".into(),
family: "claude-4".into(),
version: "latest".into(),
capabilities: vec![
ModelCapability::Generate,
ModelCapability::Code,
ModelCapability::Reasoning,
ModelCapability::ToolUse,
],
context_length: 200000,
max_output_tokens: None,
param_count: String::new(),
quantization: None,
performance: PerformanceEnvelope {
latency_p50_ms: Some(2000),
..Default::default()
},
cost: CostModel {
input_per_mtok: Some(3.0),
output_per_mtok: Some(15.0),
..Default::default()
},
source: ModelSource::RemoteApi {
endpoint: "https://api.anthropic.com/v1/messages".into(),
api_key_env: "ANTHROPIC_API_KEY".into(),
api_key_envs: vec![],
api_version: Some("2023-06-01".into()),
protocol: ApiProtocol::Anthropic,
},
tags: vec![],
supported_params: vec![],
public_benchmarks: vec![],
trust_tier: crate::schema::TrustTier::Curated,
deprecated: false,
available: false,
weights_ready: false,
};
reg.register(remote);
assert_eq!(reg.list().len(), initial_len);
let reasoning = reg.query(&ModelFilter {
capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
..Default::default()
});
assert_eq!(reasoning.len(), initial_reasoning_len);
}
#[test]
fn unregister() {
let (mut reg, _tmp) = test_registry();
let initial_len = reg.list().len();
let removed = reg.unregister("qwen/qwen3-0.6b:q8_0");
assert!(removed.is_some());
assert_eq!(reg.list().len(), initial_len - 1);
}
#[test]
fn speech_models_are_curated() {
let (reg, _tmp) = test_registry();
let stt = reg.query_by_capability(ModelCapability::SpeechToText);
let tts = reg.query_by_capability(ModelCapability::TextToSpeech);
assert_eq!(stt.len(), 3);
assert_eq!(tts.len(), 5);
let whisper = stt
.iter()
.find(|m| m.name == "Whisper-large-v3-turbo-q5_0")
.expect("whisper STT model should be curated");
assert!(whisper.is_local());
assert!(matches!(
whisper.source,
crate::schema::ModelSource::WhisperCpp { .. }
));
}
#[test]
fn qwen_8b_variants_keep_tool_use_consistent() {
let (reg, _tmp) = test_registry();
for name in ["Qwen3-8B", "Qwen3-8B-MLX"] {
let model = reg.find_by_name(name).expect("model should exist");
assert!(model.has_capability(ModelCapability::ToolUse));
assert!(model.has_capability(ModelCapability::MultiToolCall));
}
}
#[test]
fn mac_name_resolution_prefers_mlx_siblings() {
#[allow(unused_variables)]
let (reg, _tmp) = test_registry();
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
{
assert_eq!(
reg.find_by_name("Qwen3-0.6B").unwrap().id,
"mlx/qwen3-0.6b:6bit"
);
assert_eq!(
reg.find_by_name("Qwen3-1.7B").unwrap().id,
"mlx/qwen3-1.7b:3bit"
);
assert_eq!(
reg.find_by_name("Qwen3-Embedding-0.6B").unwrap().id,
"mlx/qwen3-embedding-0.6b:mxfp8"
);
}
}
#[test]
fn remote_multimodal_models_are_curated_as_vision_capable() {
let (reg, _tmp) = test_registry();
for name in [
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"gpt-5.4",
"gpt-5.4-mini",
"o3",
"o4-mini",
"gpt-4.1-mini",
"gemini-2.5-pro",
"gemini-2.5-flash",
] {
let model = reg.find_by_name(name).expect("model should exist");
assert!(
model.has_capability(ModelCapability::Vision),
"{name} should be curated as vision-capable"
);
}
}
#[test]
fn qwen25vl_entries_are_replaced_by_qwen3vl_in_builtin_catalog() {
let (reg, _tmp) = test_registry();
let stale_ids = [
"mlx/qwen2.5-vl-3b:4bit",
"mlx/qwen2.5-vl-7b:4bit",
"mlx-vlm/qwen2.5-vl-3b:4bit",
"mlx-vlm/qwen2.5-vl-7b:4bit",
"vllm-mlx/qwen2.5-vl-3b:4bit",
];
for id in stale_ids {
assert!(
reg.get(id).is_none(),
"{id} is superseded by Qwen3-VL; the catalog must not advertise it"
);
}
let vision_ids: Vec<&str> = reg
.query_by_capability(ModelCapability::Vision)
.into_iter()
.map(|model| model.id.as_str())
.collect();
for stale in stale_ids {
assert!(
!vision_ids.contains(&stale),
"{stale} must not be reachable through the Vision capability index"
);
}
assert!(
vision_ids.contains(&"mlx-vlm/qwen3-vl-2b:bf16"),
"Qwen3-VL is the supported local VL family and must route as Vision"
);
}
#[test]
fn gemini_models_are_curated_for_multimodal_tool_use() {
let (reg, _tmp) = test_registry();
for name in ["gemini-2.5-pro", "gemini-2.5-flash"] {
let model = reg.find_by_name(name).expect("model should exist");
assert!(model.has_capability(ModelCapability::Vision));
assert!(model.has_capability(ModelCapability::ToolUse));
assert!(model.has_capability(ModelCapability::MultiToolCall));
}
}
#[test]
fn model_info_publishes_declared_prices_and_keeps_unpriced_distinct_from_free() {
let (reg, _tmp) = test_registry();
let opus = reg
.list()
.into_iter()
.find(|m| m.id == "openrouter/anthropic/claude-opus-4.8")
.map(ModelInfo::from)
.expect("curated opus-4.8 row is present on first boot");
assert_eq!(opus.cost.input_per_mtok, Some(5.0));
assert_eq!(opus.cost.output_per_mtok, Some(25.0));
assert_eq!(opus.cost.cache_read_input_per_mtok, Some(0.5));
assert_eq!(opus.cost.cache_write_input_per_mtok, Some(6.25));
let gpt = reg
.list()
.into_iter()
.find(|m| m.id == "openrouter/openai/gpt-5.4")
.map(ModelInfo::from)
.expect("curated gpt-5.4 row");
assert_eq!(gpt.cost.pricing_tiers.len(), 1);
assert_eq!(gpt.cost.prices_for(272_000).input_per_mtok, Some(5.0));
let local = reg
.list()
.into_iter()
.find(|m| m.is_local() && m.cost.input_per_mtok.is_none())
.map(ModelInfo::from)
.expect("the built-in catalog ships unpriced local models");
let json = serde_json::to_value(&local).unwrap();
assert!(json["cost"]["input_per_mtok"].is_null());
assert!(json["cost"]["output_per_mtok"].is_null());
assert_ne!(json["cost"]["input_per_mtok"], serde_json::json!(0.0));
}
#[test]
fn a_hand_registered_copy_of_a_curated_id_does_not_double_the_row() {
let (mut reg, _tmp) = test_registry();
let id = "openrouter/anthropic/claude-opus-4.8";
assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
let mut copy = reg
.list()
.into_iter()
.find(|m| m.id == id)
.cloned()
.expect("curated row");
copy.name = "hand-registered".into();
reg.register_user_model(copy);
assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
}
#[test]
fn managed_alias_publishes_prices_without_disclosing_the_upstream_id_in_the_catalog_view() {
let (reg, _tmp) = test_registry();
let alias = reg
.list()
.into_iter()
.find(|m| m.id == "parslee/openrouter/frontier-deep-next")
.map(ModelInfo::from)
.expect("managed alias for the new curated row");
assert_eq!(alias.cost.input_per_mtok, Some(5.0));
assert_eq!(alias.cost.output_per_mtok, Some(25.0));
assert_eq!(alias.cost.cache_read_input_per_mtok, Some(0.5));
assert_eq!(alias.cost.cache_write_input_per_mtok, Some(6.25));
let wire = serde_json::to_string(&alias).unwrap();
assert!(!wire.contains("claude-opus-4.8"));
assert!(!wire.contains("anthropic/"));
}
#[test]
fn model_info_from_an_older_daemon_without_cost_still_parses() {
let legacy = serde_json::json!({
"id": "legacy/model",
"name": "legacy",
"provider": "legacy",
"capabilities": ["generate"],
"param_count": "",
"size_mb": 0,
"context_length": 8192,
"available": true,
"is_local": false
});
let info: ModelInfo = serde_json::from_value(legacy).expect("older catalog row parses");
assert!(info.cost.input_per_mtok.is_none());
assert!(info.cost.output_per_mtok.is_none());
assert!(info.cost.pricing_tiers.is_empty());
assert!(info.max_output_tokens.is_none());
}
#[test]
fn visual_generation_models_are_curated() {
let (reg, _tmp) = test_registry();
assert_eq!(
reg.query_by_capability(ModelCapability::ImageGeneration)
.len(),
1
);
assert_eq!(
reg.query_by_capability(ModelCapability::VideoGeneration)
.len(),
1
);
}
}