1use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::time::SystemTime;
11
12use serde::{Deserialize, Serialize};
13use tracing::{info, warn};
14
15use crate::download::{DownloadEvent, ProgressSink};
16use crate::schema::*;
17use crate::InferenceError;
18
19#[derive(Debug, Clone, Default)]
21pub struct ModelFilter {
22 pub capabilities: Vec<ModelCapability>,
24 pub max_size_mb: Option<u64>,
26 pub max_latency_ms: Option<u64>,
28 pub max_cost_per_mtok: Option<f64>,
30 pub tags: Vec<String>,
32 pub provider: Option<String>,
34 pub local_only: bool,
36 pub available_only: bool,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ModelUpgrade {
44 pub from_id: String,
45 pub from_name: String,
46 pub to_id: String,
47 pub to_name: String,
48 pub reason: String,
49 pub target_runtime: Option<String>,
50 pub target_runtime_requirement: Option<String>,
51 pub minimum_runtimes: Vec<ModelRuntimeRequirement>,
52 pub target_available: bool,
53 pub target_pullable: bool,
54 pub remove_old_supported: bool,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ModelRuntimeRequirement {
59 pub name: String,
60 pub minimum_version: String,
61}
62
63#[derive(Debug, Clone, Copy)]
77pub(crate) enum SessionProbe {
78 #[cfg_attr(test, allow(dead_code))]
84 Live,
85 #[cfg(test)]
90 Fixed(bool),
91 Inert,
96}
97
98impl SessionProbe {
99 fn available(&self) -> bool {
103 match self {
104 Self::Live => passive_parslee_oauth_available(),
105 Self::Inert => passive_parslee_oauth_available(),
106 #[cfg(test)]
107 Self::Fixed(available) => *available,
108 }
109 }
110
111 fn signed_out(&self) -> bool {
114 match self {
115 Self::Live => matches!(
116 car_auth::credential_authority_hint().state,
117 car_auth::CredentialAuthorityState::SignedOut
118 ),
119 Self::Inert => matches!(
120 car_auth::credential_authority_hint().state,
121 car_auth::CredentialAuthorityState::SignedOut
122 ),
123 #[cfg(test)]
124 Self::Fixed(available) => !available,
125 }
126 }
127
128 fn may_forget_session_evidence(&self) -> bool {
132 match self {
133 Self::Live => true,
134 #[cfg(test)]
135 Self::Fixed(_) => true,
136 Self::Inert => false,
137 }
138 }
139}
140
141#[derive(Clone)]
143pub struct UnifiedRegistry {
144 models_dir: PathBuf,
145 state_root: PathBuf,
151 models: HashMap<String, ModelSchema>,
153 project_model_ids: HashSet<String>,
156 builtin_model_ids: HashSet<String>,
159 user_config_ids: HashSet<String>,
167 user_config_path: PathBuf,
170 ambient_progress: ProgressSink,
184 session: SessionProbe,
189}
190
191#[derive(Debug, Clone, Deserialize)]
192struct ModelUpgradeRule {
193 from_ids: Vec<String>,
194 to_id: String,
195 reason: String,
196 target_runtime: Option<String>,
197 target_runtime_requirement: Option<String>,
198 #[serde(default)]
199 minimum_runtimes: Vec<ModelRuntimeRequirement>,
200 #[serde(default = "default_remove_old_after_available")]
201 remove_old_after_available: bool,
202}
203
204fn default_remove_old_after_available() -> bool {
205 true
206}
207
208fn environment_credential_available(env_var: &str) -> bool {
209 std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
210}
211
212fn passive_parslee_oauth_available() -> bool {
213 matches!(
214 car_auth::credential_authority_hint().state,
215 car_auth::CredentialAuthorityState::Configured
216 )
217}
218
219fn proprietary_auth_available(
225 model_id: &str,
226 schema_provider: &str,
227 source_provider: &str,
228 auth: &ProprietaryAuth,
229 parslee_oauth_available: bool,
230 resolved: &std::collections::HashMap<String, bool>,
231) -> bool {
232 if crate::openrouter::is_curated_managed_gateway_alias(model_id)
248 && crate::openrouter::gateway_unconfigured()
249 {
250 return false;
251 }
252 if crate::parslee_credential::credential_rejected()
266 && matches!(auth, ProprietaryAuth::OAuth2Pkce { .. })
267 {
268 return false;
269 }
270 match auth {
271 ProprietaryAuth::ApiKeyEnv { env_var } | ProprietaryAuth::BearerTokenEnv { env_var } => {
272 resolved.get(env_var).copied().unwrap_or(false)
273 }
274 ProprietaryAuth::OAuth2Pkce { .. } => {
275 schema_provider.eq_ignore_ascii_case("parslee")
276 && source_provider.eq_ignore_ascii_case("parslee")
277 && parslee_oauth_available
278 }
279 }
280}
281
282fn model_upgrade_rules() -> Vec<ModelUpgradeRule> {
283 serde_json::from_str(include_str!("../assets/model-upgrades.json"))
284 .expect("built-in model-upgrades.json should parse")
285}
286
287pub const USER_MODELS_FILE: &str = "models.json";
289
290pub fn user_config_path() -> Option<PathBuf> {
308 car_home::root().map(|root| root.join(USER_MODELS_FILE))
309}
310
311impl UnifiedRegistry {
312 pub fn new(models_dir: PathBuf) -> Self {
316 Self::new_with_state_root(car_home::root_or_relative(), models_dir)
317 }
318
319 pub fn new_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
323 let catalog_public_key = std::env::var("CAR_CATALOG_PUBKEY").ok();
324 Self::new_with_catalog_public_key(state_root, models_dir, catalog_public_key.as_deref())
325 }
326
327 fn new_with_catalog_public_key(
328 state_root: PathBuf,
329 models_dir: PathBuf,
330 catalog_public_key: Option<&str>,
331 ) -> Self {
332 #[cfg(not(test))]
340 let session = SessionProbe::Live;
341 #[cfg(test)]
342 let session = SessionProbe::Inert;
343 Self::new_with_session(state_root, models_dir, catalog_public_key, session)
344 }
345
346 pub(crate) fn new_with_session(
349 state_root: PathBuf,
350 models_dir: PathBuf,
351 catalog_public_key: Option<&str>,
352 session: SessionProbe,
353 ) -> Self {
354 let user_config_path = state_root.join(USER_MODELS_FILE);
355
356 let mut registry = Self {
357 models_dir,
358 state_root,
359 models: HashMap::new(),
360 project_model_ids: HashSet::new(),
361 builtin_model_ids: HashSet::new(),
362 user_config_ids: HashSet::new(),
363 user_config_path,
364 ambient_progress: ProgressSink::none(),
365 session,
366 };
367 registry.load_builtin_catalog();
368 for schema in crate::catalog::load_cache(
373 &crate::catalog::cache_path(®istry.state_root),
374 catalog_public_key,
375 ) {
376 registry.register_signed_catalog_model(schema);
377 }
378 for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
383 ®istry.state_models_dir(),
384 )) {
385 if !registry.models.contains_key(&schema.id) {
386 registry.register(schema);
387 }
388 }
389 registry.refresh_availability();
390 let _ = registry.load_user_config();
392 registry.discover_on_disk_models();
396 registry
397 }
398
399 fn empty_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
400 let user_config_path = state_root.join(USER_MODELS_FILE);
401 Self {
402 models_dir,
403 state_root,
404 models: HashMap::new(),
405 project_model_ids: HashSet::new(),
406 builtin_model_ids: HashSet::new(),
407 user_config_ids: HashSet::new(),
408 user_config_path,
409 ambient_progress: ProgressSink::none(),
410 session: SessionProbe::Inert,
413 }
414 }
415
416 #[cfg(test)]
431 pub fn new_empty(models_dir: PathBuf) -> Self {
432 let state_root = models_dir.parent().unwrap_or(&models_dir).to_path_buf();
433 Self::empty_with_state_root(state_root, models_dir)
434 }
435
436 pub(crate) fn new_isolated_for_diagnosis(state_root: PathBuf, models_dir: PathBuf) -> Self {
443 let mut registry = Self::empty_with_state_root(state_root, models_dir);
444 registry.discover_on_disk_models();
445 registry
446 }
447
448 fn state_models_dir(&self) -> PathBuf {
453 self.state_root.join("models")
454 }
455
456 fn discover_on_disk_models(&mut self) {
470 let entries = match std::fs::read_dir(&self.models_dir) {
471 Ok(e) => e,
472 Err(_) => return,
473 };
474 let known: std::collections::HashSet<String> = self
477 .models
478 .values()
479 .map(|m| m.name.to_ascii_lowercase())
480 .collect();
481
482 for entry in entries.flatten() {
483 let path = entry.path();
484 if !path.is_dir() {
485 continue;
486 }
487 let Some(name) = path
488 .file_name()
489 .and_then(|n| n.to_str())
490 .map(str::to_string)
491 else {
492 continue;
493 };
494 if known.contains(&name.to_ascii_lowercase()) {
495 continue;
496 }
497
498 let Some(schema) = synthesize_local_schema(&name, &path) else {
499 continue;
500 };
501 tracing::info!(
502 id = %schema.id,
503 name = %name,
504 "auto-discovered uncatalogued local model under models_dir (car-releases#62)"
505 );
506 self.register(schema);
507 }
508 }
509
510 pub fn register(&mut self, mut schema: ModelSchema) {
518 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
519 warn!(id = %schema.id, "ignoring user registration for reserved Parslee-managed alias");
520 return;
521 }
522 if self.project_model_ids.contains(&schema.id) {
523 warn!(id = %schema.id, "ignoring public registration for project-owned exact id");
524 return;
525 }
526 schema.mark_user_registered();
527 self.register_preserving_trust(schema);
528 }
529
530 pub(crate) fn register_project_model(&mut self, schema: ModelSchema) -> bool {
536 let id = schema.id.clone();
537 if !self.register_preserving_trust(schema) {
538 return false;
539 }
540 self.project_model_ids.insert(id);
541 true
542 }
543
544 fn register_signed_catalog_model(&mut self, schema: ModelSchema) {
548 if self.builtin_model_ids.contains(&schema.id) {
549 warn!(id = %schema.id, "ignoring signed catalog row for compiled builtin exact id");
550 return;
551 }
552 if self.project_model_ids.contains(&schema.id) {
553 warn!(id = %schema.id, "ignoring duplicate signed catalog row for project-owned exact id");
554 return;
555 }
556 self.register_project_model(schema);
557 }
558
559 fn register_preserving_trust(&mut self, mut schema: ModelSchema) -> bool {
560 if let Err(error) = crate::catalog_identity::row_digest(&schema) {
561 warn!(id = %schema.id, %error, "rejecting model without canonical catalog identity");
562 return false;
563 }
564 if schema.is_mlx() || schema.is_car_managed_vllm_mlx() {
566 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
577 {
578 schema.available = if schema.tags.contains(&"speech".to_string()) {
579 speech_mlx_available()
580 } else if let ModelSource::Mlx { ref hf_repo, .. }
581 | ModelSource::ManagedVllmMlx { ref hf_repo, .. } = schema.source
582 {
583 let mlx_dir = self.models_dir.join(&schema.name);
589 mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty()
590 } else {
591 let mlx_dir = self.models_dir.join(&schema.name);
592 mlx_dir_has_weights(&mlx_dir)
593 };
594 }
595 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
596 {
597 schema.available = false;
598 }
599 } else if schema.is_vllm_mlx() {
600 schema.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available;
602 } else if matches!(schema.source, ModelSource::WhisperCpp { .. }) {
603 schema.available = true;
608 } else if matches!(schema.source, ModelSource::WindowsSpeech {}) {
609 schema.available = cfg!(target_os = "windows");
611 } else if schema.is_codex_cli() {
612 schema.available = crate::backend::codex_cli::is_available();
616 } else if schema.is_local() {
617 let local_path = self.models_dir.join(&schema.name).join("model.gguf");
618 let lazily_fetchable = matches!(
624 schema.source,
625 ModelSource::Local { ref hf_repo, .. } if !hf_repo.is_empty()
626 ) && !cfg!(all(
627 target_os = "macos",
628 target_arch = "aarch64",
629 not(car_skip_mlx)
630 ));
631 schema.available = local_path.exists() || lazily_fetchable;
632 } else if schema.is_remote() {
633 schema.available = match schema.source {
637 ModelSource::RemoteApi {
638 protocol: crate::schema::ApiProtocol::OpenRouter,
639 ..
640 } => crate::openrouter::credential_source().is_some(),
641 ModelSource::RemoteApi {
642 ref api_key_env, ..
643 } => environment_credential_available(api_key_env),
644 ModelSource::Proprietary {
645 ref provider,
646 ref auth,
647 ..
648 } => {
649 let resolved = match auth {
650 ProprietaryAuth::ApiKeyEnv { env_var }
651 | ProprietaryAuth::BearerTokenEnv { env_var } => {
652 std::collections::HashMap::from([(
653 env_var.clone(),
654 environment_credential_available(env_var),
655 )])
656 }
657 ProprietaryAuth::OAuth2Pkce { .. } => Default::default(),
658 };
659 proprietary_auth_available(
660 &schema.id,
661 &schema.provider,
662 provider,
663 auth,
664 self.session.available(),
665 &resolved,
666 )
667 }
668 _ => schema.available,
669 };
670 }
671 schema.weights_ready = physical_weights_ready(&schema, &self.models_dir);
684 info!(
685 id = %schema.id,
686 name = %schema.name,
687 available = schema.available,
688 weights_ready = schema.weights_ready,
689 "registered model"
690 );
691 self.models.insert(schema.id.clone(), schema);
692 true
693 }
694
695 pub fn register_user_model(&mut self, mut schema: ModelSchema) {
702 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
703 warn!(id = %schema.id, "ignoring persisted user model for reserved Parslee-managed alias");
704 return;
705 }
706 if self.project_model_ids.contains(&schema.id) {
707 warn!(id = %schema.id, "ignoring persisted user model for project-owned exact id");
708 return;
709 }
710 schema.mark_user_registered();
711 let id = schema.id.clone();
712 if self.register_preserving_trust(schema) {
713 self.user_config_ids.insert(id);
714 }
715 }
716
717 pub fn unregister(&mut self, id: &str) -> Option<ModelSchema> {
719 let removed = self.models.remove(id);
720 if let Some(ref m) = removed {
721 info!(id = %m.id, "unregistered model");
722 }
723 removed
724 }
725
726 pub fn unregister_user_model(&mut self, id: &str) -> Option<ModelSchema> {
731 if !self.user_config_ids.remove(id) {
732 return None;
733 }
734 self.unregister(id)
735 }
736
737 pub fn list(&self) -> Vec<&ModelSchema> {
739 let mut models: Vec<&ModelSchema> = self.models.values().collect();
740 models.sort_by(|a, b| a.id.cmp(&b.id));
741 models
742 }
743
744 pub fn query(&self, filter: &ModelFilter) -> Vec<&ModelSchema> {
746 self.models
747 .values()
748 .filter(|m| {
749 if !filter.capabilities.iter().all(|c| m.has_capability(*c)) {
751 return false;
752 }
753 if let Some(max) = filter.max_size_mb {
755 if m.size_mb() > max && m.is_local() {
756 return false;
757 }
758 }
759 if let Some(max) = filter.max_latency_ms {
761 if let Some(p50) = m.performance.latency_p50_ms {
762 if p50 > max {
763 return false;
764 }
765 }
766 }
767 if let Some(max) = filter.max_cost_per_mtok {
769 if let Some(cost) = m.cost.output_per_mtok {
770 if cost > max {
771 return false;
772 }
773 }
774 }
775 if !filter.tags.iter().all(|t| m.tags.contains(t)) {
777 return false;
778 }
779 if let Some(ref p) = filter.provider {
781 if &m.provider != p {
782 return false;
783 }
784 }
785 if filter.local_only && !m.is_local() {
787 return false;
788 }
789 if filter.available_only && !m.available_now() {
791 return false;
792 }
793 true
794 })
795 .collect()
796 }
797
798 pub fn query_by_capability(&self, cap: ModelCapability) -> Vec<&ModelSchema> {
800 self.query(&ModelFilter {
801 capabilities: vec![cap],
802 ..Default::default()
803 })
804 }
805
806 pub fn available_upgrades(&self) -> Vec<ModelUpgrade> {
808 let mut upgrades = Vec::new();
809 for rule in model_upgrade_rules() {
810 let Some(from) = rule
811 .from_ids
812 .iter()
813 .find_map(|id| self.models.get(id.as_str()))
814 .filter(|schema| schema.available)
815 else {
816 continue;
817 };
818 let Some(to) = self.models.get(rule.to_id.as_str()) else {
819 continue;
820 };
821 upgrades.push(ModelUpgrade {
822 from_id: from.id.clone(),
823 from_name: from.name.clone(),
824 to_id: to.id.clone(),
825 to_name: to.name.clone(),
826 reason: rule.reason.clone(),
827 target_runtime: rule.target_runtime.clone(),
828 target_runtime_requirement: rule.target_runtime_requirement.clone(),
829 minimum_runtimes: rule.minimum_runtimes.clone(),
830 target_available: to.available,
831 target_pullable: matches!(
832 to.source,
833 ModelSource::Local { .. } | ModelSource::Mlx { .. }
834 ),
835 remove_old_supported: matches!(
836 from.source,
837 ModelSource::Local { .. } | ModelSource::Mlx { .. }
838 ) && rule.remove_old_after_available,
839 });
840 }
841 upgrades.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
842 upgrades.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
843 upgrades
844 }
845
846 pub fn get(&self, id: &str) -> Option<&ModelSchema> {
848 self.models.get(id)
849 }
850
851 pub fn registered_schema(&self, id: &str) -> Option<&ModelSchema> {
857 self.get(id)
858 }
859
860 pub fn all(&self) -> impl Iterator<Item = &ModelSchema> {
862 self.models.values()
863 }
864
865 pub fn find_by_name(&self, name: &str) -> Option<&ModelSchema> {
868 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
869 if !name.to_ascii_lowercase().ends_with("-mlx") {
870 if let Some(mlx_variant) = self
871 .models
872 .values()
873 .find(|m| m.name.eq_ignore_ascii_case(&format!("{name}-MLX")))
874 {
875 return Some(mlx_variant);
876 }
877 }
878
879 self.models
880 .values()
881 .find(|m| m.name.eq_ignore_ascii_case(name))
882 }
883
884 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
888 pub fn resolve_mlx_equivalent(&self, schema: &ModelSchema) -> Option<&ModelSchema> {
889 if schema.is_mlx() || schema.is_vllm_mlx() {
891 return None;
892 }
893 if !matches!(schema.source, ModelSource::Local { .. }) {
895 return None;
896 }
897 let primary_cap = schema.capabilities.first()?;
904 self.models.values().find(|m| {
905 m.is_mlx()
906 && m.family == schema.family
907 && m.param_count == schema.param_count
908 && m.capabilities.contains(primary_cap)
909 })
910 }
911
912 pub async fn ensure_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
914 let sink = self.ambient_progress.clone();
919 self.ensure_local_with_progress(id, &sink).await
920 }
921
922 pub fn set_ambient_progress(&mut self, sink: ProgressSink) {
929 self.ambient_progress = sink;
930 }
931
932 pub async fn ensure_local_with_progress(
937 &self,
938 id: &str,
939 sink: &ProgressSink,
940 ) -> Result<PathBuf, InferenceError> {
941 self.acquire_and_ensure(id, sink, false, None).await
942 }
943
944 pub(crate) async fn ensure_local_with_progress_staged(
949 &self,
950 id: &str,
951 sink: &ProgressSink,
952 staging_dir: &Path,
953 ) -> Result<PathBuf, InferenceError> {
954 self.acquire_and_ensure(id, sink, false, Some(staging_dir))
955 .await
956 }
957
958 pub async fn redownload_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
964 self.acquire_and_ensure(id, &ProgressSink::none(), true, None)
965 .await
966 }
967
968 async fn acquire_and_ensure(
969 &self,
970 id: &str,
971 sink: &ProgressSink,
972 force: bool,
973 managed_dir_override: Option<&Path>,
974 ) -> Result<PathBuf, InferenceError> {
975 let schema = self
976 .get(id)
977 .or_else(|| self.find_by_name(id))
978 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
979 let model_name = schema.name.clone();
980 let model_id = schema.id.clone();
981 let needed_mb = schema.size_mb();
982 let model_dir = self.models_dir.join(&schema.name);
983
984 let _guard = crate::download::acquire_model_lock(&model_id).await;
986
987 if let Err(e) = crate::download::check_disk_space(&model_dir, needed_mb) {
989 sink.emit(DownloadEvent::Failed { error: e.clone() });
990 return Err(InferenceError::DownloadFailed(e));
991 }
992
993 sink.emit(DownloadEvent::Started {
994 model: model_name.clone(),
995 total_files: 0,
996 total_mb: needed_mb,
997 });
998 let result = self
999 .ensure_local_inner(id, sink, force, managed_dir_override)
1000 .await;
1001 match &result {
1002 Ok(_) => sink.emit(DownloadEvent::Completed { model: model_name }),
1003 Err(e) => sink.emit(DownloadEvent::Failed {
1004 error: e.to_string(),
1005 }),
1006 }
1007 result
1008 }
1009
1010 async fn ensure_local_inner(
1011 &self,
1012 id: &str,
1013 sink: &ProgressSink,
1014 force: bool,
1015 managed_dir_override: Option<&Path>,
1016 ) -> Result<PathBuf, InferenceError> {
1017 let schema = self
1018 .get(id)
1019 .or_else(|| self.find_by_name(id))
1020 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1021
1022 match &schema.source {
1023 ModelSource::Local {
1024 hf_repo,
1025 hf_filename,
1026 tokenizer_repo,
1027 } => {
1028 let model_dir = managed_dir_override
1029 .map(Path::to_path_buf)
1030 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1031 let model_path = model_dir.join("model.gguf");
1032 let tokenizer_path = model_dir.join("tokenizer.json");
1033
1034 if !force
1035 && crate::download::cache_file_usable(&model_path)
1036 && crate::download::cache_file_usable(&tokenizer_path)
1037 {
1038 return Ok(model_dir);
1039 }
1040
1041 if hf_repo.is_empty() {
1048 let missing = [
1049 ("model.gguf", &model_path),
1050 ("tokenizer.json", &tokenizer_path),
1051 ]
1052 .into_iter()
1053 .filter(|(_, path)| !crate::download::cache_file_usable(path))
1054 .map(|(name, _)| name)
1055 .collect::<Vec<_>>()
1056 .join(" and ");
1057 return Err(InferenceError::InferenceFailed(format!(
1058 "{}: discovered on disk at {} but not loadable — the GGUF \
1059 backend reads `model.gguf` and `tokenizer.json` from the \
1060 model directory, and this one is missing {missing}. Rename \
1061 the weight file to `model.gguf` and add the tokenizer, or \
1062 register the model against its HuggingFace repo so CAR can \
1063 fetch both.",
1064 schema.name,
1065 model_dir.display()
1066 )));
1067 }
1068
1069 std::fs::create_dir_all(&model_dir)?;
1070
1071 if !crate::download::cache_file_usable(&model_path) {
1072 info!(model = %schema.name, repo = %hf_repo, "downloading model weights");
1073 sink.emit(DownloadEvent::FileStarted {
1074 filename: "model weights".into(),
1075 index: 1,
1076 total_files: 2,
1077 size_mb: schema.size_mb(),
1078 });
1079 download_file(hf_repo, hf_filename, &model_path).await?;
1080 sink.emit(DownloadEvent::FileCompleted {
1081 filename: "model weights".into(),
1082 });
1083 }
1084 if !crate::download::cache_file_usable(&tokenizer_path) {
1085 info!(model = %schema.name, repo = %tokenizer_repo, "downloading tokenizer");
1086 sink.emit(DownloadEvent::FileStarted {
1087 filename: "tokenizer".into(),
1088 index: 2,
1089 total_files: 2,
1090 size_mb: 0,
1091 });
1092 download_file(tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
1093 sink.emit(DownloadEvent::FileCompleted {
1094 filename: "tokenizer".into(),
1095 });
1096 }
1097
1098 Ok(model_dir)
1099 }
1100 ModelSource::Mlx {
1101 hf_repo,
1102 hf_weight_file,
1103 }
1104 | ModelSource::ManagedVllmMlx {
1105 hf_repo,
1106 hf_weight_file,
1107 } => {
1108 let model_dir = managed_dir_override
1109 .map(Path::to_path_buf)
1110 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1111 let config_path = model_dir.join("config.json");
1112
1113 let is_diffusers = schema.capabilities.iter().any(|c| {
1124 matches!(
1125 c,
1126 ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
1127 )
1128 });
1129
1130 if !force
1138 && mlx_dir_has_weights(&model_dir)
1139 && (is_diffusers || config_path.exists())
1140 {
1141 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1142 info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1143 return Ok(model_dir);
1144 }
1145
1146 if !force {
1149 if let Some(snapshot_dir) =
1150 latest_huggingface_repo_snapshot(hf_repo).filter(|d| mlx_dir_has_weights(d))
1151 {
1152 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &snapshot_dir).await?;
1153 info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1154 return Ok(snapshot_dir);
1155 }
1156 }
1157
1158 std::fs::create_dir_all(&model_dir)?;
1159
1160 info!(model = %schema.name, repo = %hf_repo, "downloading MLX model");
1161
1162 if is_diffusers {
1169 download_repo_snapshot(hf_repo, &model_dir, sink).await?;
1170 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1171 if !mlx_dir_has_weights(&model_dir) {
1172 return Err(InferenceError::DownloadFailed(format!(
1173 "{hf_repo}: snapshot fetched but no component weights found"
1174 )));
1175 }
1176 info!(model = %schema.name, path = %model_dir.display(), "downloaded diffusers model");
1177 return Ok(model_dir);
1178 }
1179
1180 emit_file(sink, "config", 0, schema.size_mb());
1184 download_file(hf_repo, "config.json", &config_path).await?;
1185 download_tokenizer_assets(hf_repo, &model_dir, sink).await;
1186 let tok_config_path = model_dir.join("tokenizer_config.json");
1187 if !crate::download::cache_file_usable(&tok_config_path) {
1188 let _ = download_file(hf_repo, "tokenizer_config.json", &tok_config_path).await;
1189 }
1190
1191 if let Some(ref wf) = hf_weight_file {
1193 let wf_path = model_dir.join(wf);
1194 if !crate::download::cache_file_usable(&wf_path) {
1195 emit_file(sink, "model weights", 0, schema.size_mb());
1196 download_file(hf_repo, wf, &wf_path).await?;
1197 }
1198 } else {
1199 let single = model_dir.join("model.safetensors");
1201 if !crate::download::cache_file_usable(&single) {
1202 emit_file(sink, "model weights", 0, schema.size_mb());
1203 match download_file(hf_repo, "model.safetensors", &single).await {
1204 Ok(()) => {}
1205 Err(_) => {
1206 let index_path = model_dir.join("model.safetensors.index.json");
1208 download_file(hf_repo, "model.safetensors.index.json", &index_path)
1209 .await?;
1210
1211 let index_json: serde_json::Value =
1212 serde_json::from_str(&std::fs::read_to_string(&index_path)?)
1213 .map_err(|e| {
1214 InferenceError::InferenceFailed(format!(
1215 "parse index: {e}"
1216 ))
1217 })?;
1218
1219 if let Some(weight_map) =
1220 index_json.get("weight_map").and_then(|m| m.as_object())
1221 {
1222 let mut files: std::collections::HashSet<String> =
1223 std::collections::HashSet::new();
1224 for filename in weight_map.values() {
1225 if let Some(f) = filename.as_str() {
1226 files.insert(f.to_string());
1227 }
1228 }
1229 let shard_total = files.len() as u32;
1230 for (i, file) in files.iter().enumerate() {
1231 let dest = model_dir.join(file);
1232 if !crate::download::cache_file_usable(&dest) {
1233 info!(file = %file, "downloading weight shard");
1234 sink.emit(DownloadEvent::FileStarted {
1235 filename: format!("weights part {}", i + 1),
1236 index: (i + 1) as u32,
1237 total_files: shard_total,
1238 size_mb: 0,
1239 });
1240 download_file(hf_repo, file, &dest).await?;
1241 sink.emit(DownloadEvent::FileCompleted {
1242 filename: format!("weights part {}", i + 1),
1243 });
1244 }
1245 }
1246 }
1247 }
1248 }
1249 }
1250 }
1251
1252 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1253
1254 let missing = missing_weight_shards(&model_dir);
1266 if !missing.is_empty() {
1267 return Err(InferenceError::DownloadFailed(format!(
1268 "{}: pull finished but {} weight shard(s) are still missing: {}. \
1269 The download was interrupted; re-run the pull to resume it.",
1270 schema.name,
1271 missing.len(),
1272 missing.join(", ")
1273 )));
1274 }
1275 if !mlx_dir_has_weights(&model_dir) {
1276 return Err(InferenceError::DownloadFailed(format!(
1277 "{}: pull finished but no usable weights are present under {}",
1278 schema.name,
1279 model_dir.display()
1280 )));
1281 }
1282 Ok(model_dir)
1283 }
1284 _ => Err(InferenceError::InferenceFailed(format!(
1285 "model {} is not local",
1286 id
1287 ))),
1288 }
1289 }
1290
1291 #[deprecated(note = "use InferenceEngine::remove_model_from_car")]
1295 pub fn remove_local(&mut self, id: &str) -> Result<(), InferenceError> {
1296 Err(InferenceError::InferenceFailed(format!(
1297 "legacy registry removal for {id} is disabled; use receipt-backed model management"
1298 )))
1299 }
1300
1301 pub fn refresh_availability(&mut self) {
1307 let parslee_oauth_available = self.session.available();
1308 self.refresh_availability_with(
1309 parslee_oauth_available,
1310 self.session.signed_out() && self.session.may_forget_session_evidence(),
1311 false,
1312 );
1313 }
1314
1315 pub(crate) fn refresh_routing_availability(
1320 &mut self,
1321 parslee_api_base: Option<&str>,
1322 parslee_signed_out: bool,
1323 ) {
1324 if let Some(api_base) = parslee_api_base {
1325 let api_base = api_base.trim_end_matches('/');
1326 for schema in self.models.values_mut() {
1327 if schema.provider.eq_ignore_ascii_case("parslee") {
1328 if let ModelSource::Proprietary {
1329 provider, endpoint, ..
1330 } = &mut schema.source
1331 {
1332 if provider.eq_ignore_ascii_case("parslee") {
1333 *endpoint = api_base.to_string();
1334 }
1335 }
1336 }
1337 }
1338 }
1339 self.refresh_availability_with(parslee_api_base.is_some(), parslee_signed_out, true);
1340 }
1341
1342 fn refresh_availability_with(
1343 &mut self,
1344 parslee_oauth_available: bool,
1345 clear_parslee_observations: bool,
1346 authoritative_credentials: bool,
1347 ) {
1348 let models_dir = self.models_dir.clone();
1352 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1357 let mlx_vlm_cli_present = crate::backend::mlx_vlm_cli::is_available();
1358 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1359 #[allow(unused_variables)]
1360 let mlx_vlm_cli_present = false;
1361 if clear_parslee_observations {
1366 crate::openrouter::clear_gateway_unconfigured();
1367 crate::parslee_credential::clear_credential_rejected();
1371 }
1372
1373 let mut credential_envs: std::collections::BTreeSet<String> = Default::default();
1391 let mut needs_openrouter = false;
1392 for m in self.models.values() {
1393 match &m.source {
1394 ModelSource::RemoteApi {
1395 protocol: crate::schema::ApiProtocol::OpenRouter,
1396 ..
1397 } => needs_openrouter = true,
1398 ModelSource::RemoteApi { api_key_env, .. } => {
1399 credential_envs.insert(api_key_env.clone());
1400 }
1401 ModelSource::Proprietary { auth, .. } => match auth {
1402 ProprietaryAuth::ApiKeyEnv { env_var }
1403 | ProprietaryAuth::BearerTokenEnv { env_var } => {
1404 credential_envs.insert(env_var.clone());
1405 }
1406 ProprietaryAuth::OAuth2Pkce { .. } => {}
1407 },
1408 _ => {}
1409 }
1410 }
1411 let credential_available: std::collections::HashMap<String, bool> = credential_envs
1412 .into_iter()
1413 .map(|env| {
1414 let available = if authoritative_credentials {
1415 car_secrets::resolve_env_or_keychain(&env).is_some()
1416 } else {
1417 environment_credential_available(&env)
1418 };
1419 (env, available)
1420 })
1421 .collect();
1422 let openrouter_available = needs_openrouter
1425 && if authoritative_credentials {
1426 crate::openrouter::refresh_credential_source().is_some()
1427 } else {
1428 crate::openrouter::credential_source().is_some()
1429 };
1430
1431 for m in self.models.values_mut() {
1432 match &m.source {
1433 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1434 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1443 {
1444 let needs_mlx_vlm = m.tags.iter().any(|t| t == "requires-mlx-vlm");
1451
1452 m.available = if needs_mlx_vlm {
1453 mlx_vlm_cli_present
1454 } else if m.tags.contains(&"speech".to_string()) {
1455 speech_mlx_available()
1456 } else {
1457 let mlx_dir = models_dir.join(&m.name);
1467 mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty()
1468 };
1469 }
1470 #[cfg(not(all(
1471 target_os = "macos",
1472 target_arch = "aarch64",
1473 not(car_skip_mlx)
1474 )))]
1475 {
1476 let _ = hf_repo; m.available = false;
1478 }
1479 }
1480 ModelSource::Local {
1481 hf_repo: local_repo,
1482 ..
1483 } => {
1484 let local_path = models_dir.join(&m.name).join("model.gguf");
1485 #[cfg(not(all(
1500 target_os = "macos",
1501 target_arch = "aarch64",
1502 not(car_skip_mlx)
1503 )))]
1504 {
1505 m.available = local_path.exists() || !local_repo.is_empty();
1506 }
1507 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1512 {
1513 let _ = local_repo;
1514 m.available = local_path.exists();
1515 }
1516 }
1517 ModelSource::WhisperCpp { .. } => {
1518 m.available = true;
1523 }
1524 ModelSource::WindowsSpeech {} => {
1525 #[cfg(target_os = "windows")]
1528 {
1529 m.available = true;
1530 }
1531 #[cfg(not(target_os = "windows"))]
1532 {
1533 m.available = false;
1534 }
1535 }
1536 ModelSource::RemoteApi {
1537 protocol: crate::schema::ApiProtocol::OpenRouter,
1538 ..
1539 } => {
1540 m.available = openrouter_available;
1541 }
1542 ModelSource::RemoteApi { api_key_env, .. } => {
1543 m.available = credential_available
1546 .get(api_key_env)
1547 .copied()
1548 .unwrap_or(false);
1549 }
1550 ModelSource::CodexCli { .. } => {
1551 m.available = crate::backend::codex_cli::is_available();
1552 }
1553 ModelSource::Ollama { .. } => {
1554 m.available = true;
1556 }
1557 ModelSource::VllmMlx { .. } => {
1558 m.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || m.available;
1562 }
1563 ModelSource::Proprietary { provider, auth, .. } => {
1564 m.available = proprietary_auth_available(
1565 &m.id,
1566 &m.provider,
1567 provider,
1568 auth,
1569 parslee_oauth_available,
1570 &credential_available,
1571 );
1572 }
1573 ModelSource::AppleFoundationModels { .. } => {
1574 #[cfg(any(
1581 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
1582 all(target_os = "ios", target_arch = "aarch64")
1583 ))]
1584 {
1585 m.available = crate::backend::foundation_models::is_available();
1586 }
1587 #[cfg(not(any(
1588 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
1589 all(target_os = "ios", target_arch = "aarch64")
1590 )))]
1591 {
1592 m.available = false;
1593 }
1594 }
1595 ModelSource::Delegated { .. } => {
1596 m.available = crate::runner::current_inference_runner().is_some();
1601 }
1602 }
1603 m.weights_ready = physical_weights_ready(m, &models_dir);
1604 }
1605 }
1606
1607 pub fn save_user_config(&self) -> Result<(), InferenceError> {
1609 let mut user_models: Vec<ModelSchema> = self
1610 .user_config_ids
1611 .iter()
1612 .filter_map(|id| self.models.get(id))
1613 .cloned()
1614 .map(|mut model| {
1615 model.mark_user_registered();
1618 model
1619 })
1620 .collect();
1621 user_models.sort_by(|a, b| a.id.cmp(&b.id));
1622
1623 for model in &user_models {
1624 crate::catalog_identity::row_digest(model).map_err(|error| {
1625 InferenceError::InferenceFailed(format!(
1626 "refuse to persist model without canonical catalog identity: {error}"
1627 ))
1628 })?;
1629 }
1630
1631 let json = serde_json::to_string_pretty(&user_models)
1632 .map_err(|e| InferenceError::InferenceFailed(format!("serialize: {e}")))?;
1633 std::fs::write(&self.user_config_path, json)?;
1634 Ok(())
1635 }
1636
1637 pub fn load_user_config(&mut self) -> Result<(), InferenceError> {
1639 if !self.user_config_path.exists() {
1640 return Ok(());
1641 }
1642
1643 let json = std::fs::read_to_string(&self.user_config_path)?;
1644 let models: Vec<ModelSchema> = serde_json::from_str(&json)
1645 .map_err(|e| InferenceError::InferenceFailed(format!("parse models.json: {e}")))?;
1646
1647 for m in models {
1648 self.register_user_model(m);
1651 }
1652 Ok(())
1653 }
1654
1655 pub fn models_dir(&self) -> &Path {
1657 &self.models_dir
1658 }
1659
1660 pub fn ready_without_download(&self, id: &str) -> Option<bool> {
1668 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
1669 Some(match &schema.source {
1670 ModelSource::Local { .. } => {
1671 let model_dir = self.models_dir.join(&schema.name);
1672 crate::download::cache_file_usable(&model_dir.join("model.gguf"))
1673 && crate::download::cache_file_usable(&model_dir.join("tokenizer.json"))
1674 }
1675 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1676 let managed_dir = self.models_dir.join(&schema.name);
1677 let managed_ready = mlx_snapshot_complete(schema, &managed_dir)
1678 && crate::download::cache_file_usable(&managed_dir.join("tokenizer.json"));
1679 let snapshot_ready =
1680 latest_huggingface_repo_snapshot(hf_repo).is_some_and(|snapshot| {
1681 mlx_snapshot_complete(schema, &snapshot)
1682 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
1683 });
1684 managed_ready || snapshot_ready
1685 }
1686 ModelSource::WindowsSpeech {} => true, ModelSource::WhisperCpp { model } => {
1688 car_whisper::model_cached(model)
1692 }
1693 ModelSource::RemoteApi { .. }
1694 | ModelSource::CodexCli { .. }
1695 | ModelSource::Ollama { .. }
1696 | ModelSource::VllmMlx { .. }
1697 | ModelSource::AppleFoundationModels { .. }
1698 | ModelSource::Proprietary { .. }
1699 | ModelSource::Delegated { .. } => true,
1700 })
1701 }
1702
1703 pub fn existing_local_artifact(&self, id: &str) -> Option<PathBuf> {
1707 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
1708 let managed = self.models_dir.join(&schema.name);
1709 if std::fs::symlink_metadata(&managed).is_ok()
1710 && self.ready_without_download(&schema.id) == Some(true)
1711 {
1712 return Some(managed);
1713 }
1714 match &schema.source {
1715 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1716 latest_huggingface_repo_snapshot(hf_repo).filter(|snapshot| {
1717 mlx_snapshot_complete(schema, snapshot)
1718 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
1719 })
1720 }
1721 _ => None,
1722 }
1723 }
1724
1725 fn load_builtin_catalog(&mut self) {
1727 for schema in builtin_catalog() {
1728 let id = schema.id.clone();
1729 if self.register_project_model(schema) {
1730 self.builtin_model_ids.insert(id);
1731 }
1732 }
1733 }
1734}
1735
1736fn synthesize_local_schema(name: &str, dir: &Path) -> Option<ModelSchema> {
1746 let lower = name.to_ascii_lowercase();
1747
1748 const NON_TEXT_HINTS: &[&str] = &[
1751 "vad",
1752 "whisper",
1753 "parakeet",
1754 "kokoro",
1755 "tts",
1756 "stt",
1757 "flux",
1758 "ltx",
1759 "yume",
1760 "sd-",
1761 "stable-diffusion",
1762 "wan",
1763 "mochi",
1764 "sana",
1765 "diffusion",
1766 ];
1767 if NON_TEXT_HINTS.iter().any(|h| lower.contains(h)) {
1768 return None;
1769 }
1770
1771 let capabilities: Vec<ModelCapability> =
1774 if lower.contains("embedding") || lower.contains("embed") {
1775 vec![ModelCapability::Embed]
1776 } else if lower.contains("reranker") || lower.contains("rerank") {
1777 vec![ModelCapability::Rerank]
1778 } else {
1779 vec![
1780 ModelCapability::Generate,
1781 ModelCapability::Code,
1782 ModelCapability::Reasoning,
1783 ]
1784 };
1785
1786 let config_path = dir.join("config.json");
1788 let has_safetensors =
1789 dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists();
1790
1791 let (source, context_length, quantization) = if config_path.exists() && has_safetensors {
1792 let cfg: serde_json::Value = std::fs::read_to_string(&config_path)
1794 .ok()
1795 .and_then(|s| serde_json::from_str(&s).ok())?;
1796 let model_type = cfg
1797 .get("model_type")
1798 .and_then(|v| v.as_str())
1799 .unwrap_or("")
1800 .to_ascii_lowercase();
1801 const KNOWN_LLM_TYPES: &[&str] = &[
1802 "qwen",
1803 "qwen2",
1804 "qwen3",
1805 "qwen3_moe",
1806 "llama",
1807 "mistral",
1808 "mixtral",
1809 "gemma",
1810 "gemma2",
1811 "gemma3",
1812 "gemma4_unified",
1813 "gemma4_unified_text",
1814 "phi",
1815 "phi3",
1816 "phimoe",
1817 "starcoder2",
1818 "deepseek",
1819 "deepseek_v2",
1820 "internlm2",
1821 "cohere",
1822 "olmo",
1823 ];
1824 if !KNOWN_LLM_TYPES.iter().any(|t| model_type == *t) {
1825 return None;
1826 }
1827 let ctx = cfg
1828 .get("max_position_embeddings")
1829 .and_then(|v| v.as_u64())
1830 .unwrap_or(32_768) as usize;
1831 let quant = cfg
1837 .get("quantization")
1838 .filter(|q| q.is_object())
1839 .and_then(|q| {
1840 let bits = q
1841 .get("bits")
1842 .and_then(|b| b.as_u64())
1843 .and_then(|b| u8::try_from(b).ok());
1844 let group_size = q
1845 .get("group_size")
1846 .and_then(|g| g.as_u64())
1847 .and_then(|g| u32::try_from(g).ok());
1848 let mode = q.get("mode").and_then(|m| m.as_str());
1849 crate::schema::Quantization::from_mlx_config(bits, group_size, mode)
1850 });
1851 (
1852 serde_json::json!({ "type": "mlx", "hf_repo": "" }),
1853 ctx,
1854 quant,
1855 )
1856 } else {
1857 let gguf = std::fs::read_dir(dir).ok().and_then(|rd| {
1858 rd.flatten().map(|e| e.path()).find(|p| {
1859 p.extension()
1860 .and_then(|x| x.to_str())
1861 .is_some_and(|x| x.eq_ignore_ascii_case("gguf"))
1862 })
1863 })?;
1864 let filename = gguf
1866 .file_name()
1867 .and_then(|n| n.to_str())
1868 .unwrap_or("model.gguf")
1869 .to_string();
1870 let quant = crate::schema::Quantization::from_gguf_filename(&filename);
1874 (
1875 serde_json::json!({
1876 "type": "local",
1877 "hf_repo": "",
1878 "hf_filename": filename,
1879 "tokenizer_repo": "",
1880 }),
1881 4_096,
1882 quant,
1883 )
1884 };
1885
1886 let id = format!("local/{}", lower.replace(['/', ' '], "-"));
1887 serde_json::from_value(serde_json::json!({
1888 "id": id,
1889 "name": name,
1890 "provider": "local",
1891 "family": "local",
1892 "capabilities": capabilities,
1893 "context_length": context_length,
1894 "quantization": quantization,
1895 "source": source,
1896 "tags": ["auto-discovered"],
1897 "trust_tier": "community",
1898 }))
1899 .ok()
1900}
1901
1902#[allow(dead_code)] fn speech_mlx_available() -> bool {
1904 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1907 {
1908 true
1909 }
1910
1911 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1913 {
1914 let runtime_root = speech_runtime_root();
1919 crate::managed_venv::venv_program(&runtime_root, "mlx_audio.stt.generate").exists()
1920 || crate::managed_venv::venv_program(&runtime_root, "mlx_audio.tts.generate").exists()
1921 }
1922}
1923
1924#[allow(dead_code)] fn speech_runtime_root() -> PathBuf {
1926 if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
1927 if !path.trim().is_empty() {
1928 return PathBuf::from(path);
1929 }
1930 }
1931 std::env::var_os("HOME")
1932 .or_else(|| std::env::var_os("USERPROFILE"))
1933 .map(PathBuf::from)
1934 .unwrap_or_else(|| PathBuf::from("."))
1935 .join(".car")
1936 .join("speech-runtime")
1937}
1938
1939#[derive(Debug, Clone, Serialize, Deserialize)]
1941pub struct ModelInfo {
1942 pub id: String,
1943 pub name: String,
1944 pub provider: String,
1945 pub capabilities: Vec<ModelCapability>,
1946 pub param_count: String,
1947 pub size_mb: u64,
1948 pub context_length: usize,
1949 pub available: bool,
1950 pub is_local: bool,
1951 #[serde(default)]
1955 pub operator_managed_external_runtime: bool,
1956 #[serde(default)]
1964 pub weights_ready: bool,
1965 #[serde(default)]
1978 pub downloads_weights: bool,
1979 #[serde(default)]
1983 pub max_output_tokens: Option<usize>,
1984 #[serde(default)]
1988 pub public_benchmarks: Vec<crate::schema::BenchmarkScore>,
1989 #[serde(default)]
2004 pub cost: crate::schema::CostModel,
2005 #[serde(default = "default_true")]
2006 pub car_enabled: bool,
2007 #[serde(default)]
2008 pub can_remove: bool,
2009 #[serde(default)]
2010 pub in_use: bool,
2011 #[serde(default)]
2012 pub management_evidence: Option<String>,
2013 #[serde(default)]
2024 pub fit: crate::recommend::ModelFitStatus,
2025 #[serde(default)]
2028 pub estimated_peak_mb: Option<u64>,
2029 #[serde(default = "default_true")]
2034 pub platform_compatible: bool,
2035 #[serde(default)]
2039 pub deprecated: bool,
2040 #[serde(default)]
2046 pub family: Option<String>,
2047 #[serde(default)]
2051 pub version: Option<String>,
2052}
2053
2054fn default_true() -> bool {
2055 true
2056}
2057
2058impl ModelInfo {
2059 pub fn with_fit(mut self, fit: crate::recommend::ModelFit) -> Self {
2063 self.fit = fit.fit;
2064 self.estimated_peak_mb = fit.estimated_peak_mb;
2065 self.platform_compatible = fit.platform_compatible;
2066 self
2067 }
2068}
2069
2070impl From<&ModelSchema> for ModelInfo {
2071 fn from(s: &ModelSchema) -> Self {
2072 ModelInfo {
2073 id: s.id.clone(),
2074 name: s.name.clone(),
2075 provider: s.provider.clone(),
2076 capabilities: s.capabilities.clone(),
2077 param_count: s.param_count.clone(),
2078 size_mb: s.size_mb(),
2079 context_length: s.context_length,
2080 available: s.available_now(),
2081 is_local: s.is_local(),
2082 operator_managed_external_runtime: matches!(s.source, ModelSource::VllmMlx { .. }),
2083 weights_ready: s.weights_ready,
2084 downloads_weights: s.downloads_weights(),
2085 max_output_tokens: s.max_output_tokens,
2086 public_benchmarks: s.public_benchmarks.clone(),
2087 cost: s.cost.clone(),
2092 car_enabled: true,
2093 can_remove: false,
2094 in_use: false,
2095 management_evidence: None,
2096 fit: crate::recommend::ModelFitStatus::Unknown,
2101 estimated_peak_mb: None,
2102 platform_compatible: true,
2103 deprecated: s.deprecated,
2104 family: s.is_local().then(|| s.family.clone()),
2107 version: s.is_local().then(|| s.version.clone()),
2108 }
2109 }
2110}
2111
2112fn emit_file(sink: &ProgressSink, name: &str, index: u32, size_mb: u64) {
2117 sink.emit(DownloadEvent::FileStarted {
2118 filename: name.to_string(),
2119 index,
2120 total_files: 0,
2121 size_mb,
2122 });
2123}
2124
2125async fn download_repo_snapshot(
2134 repo: &str,
2135 model_dir: &Path,
2136 sink: &ProgressSink,
2137) -> Result<(), InferenceError> {
2138 #[derive(serde::Deserialize)]
2139 struct RepoInfo {
2140 siblings: Vec<Sibling>,
2141 }
2142 #[derive(serde::Deserialize)]
2143 struct Sibling {
2144 rfilename: String,
2145 }
2146 let url = format!("https://huggingface.co/api/models/{repo}");
2147 let info: RepoInfo = crate::tls_client::model_download_client()
2152 .get(&url)
2153 .send()
2154 .await
2155 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2156 .error_for_status()
2157 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2158 .json()
2159 .await
2160 .map_err(|e| InferenceError::DownloadFailed(format!("parse {repo} file list: {e}")))?;
2161
2162 let files: Vec<String> = info
2163 .siblings
2164 .into_iter()
2165 .map(|s| s.rfilename)
2166 .filter(|f| !f.starts_with('.') && !f.to_ascii_lowercase().ends_with(".md"))
2167 .collect();
2168 if files.is_empty() {
2169 return Err(InferenceError::DownloadFailed(format!(
2170 "{repo}: repo lists no downloadable files"
2171 )));
2172 }
2173
2174 let total = files.len() as u32;
2175 for (i, fname) in files.iter().enumerate() {
2176 let dest = model_dir.join(fname);
2177 if crate::download::cache_file_usable(&dest) {
2178 continue;
2179 }
2180 if let Some(parent) = dest.parent() {
2181 std::fs::create_dir_all(parent)?;
2182 }
2183 sink.emit(DownloadEvent::FileStarted {
2184 filename: fname.clone(),
2185 index: (i + 1) as u32,
2186 total_files: total,
2187 size_mb: 0,
2188 });
2189 download_file(repo, fname, &dest).await?;
2190 sink.emit(DownloadEvent::FileCompleted {
2191 filename: fname.clone(),
2192 });
2193 }
2194 Ok(())
2195}
2196
2197const TOKENIZER_FILENAMES: &[&str] = &[
2208 "tokenizer.json",
2209 "vocab.json",
2210 "merges.txt",
2211 "tokenizer.model",
2212 "tokenizer.vocab",
2213 "vocab.txt",
2214];
2215
2216async fn download_tokenizer_assets(hf_repo: &str, model_dir: &Path, sink: &ProgressSink) {
2236 if TOKENIZER_FILENAMES
2238 .iter()
2239 .any(|f| crate::download::cache_file_usable(&model_dir.join(f)))
2240 {
2241 return;
2242 }
2243 emit_file(sink, "tokenizer", 0, 0);
2244 let mut fetched: Vec<&str> = Vec::new();
2245 for name in TOKENIZER_FILENAMES {
2246 let dest = model_dir.join(name);
2247 if crate::download::cache_file_usable(&dest) {
2248 continue;
2249 }
2250 if download_file(hf_repo, name, &dest).await.is_ok() {
2251 fetched.push(name);
2252 }
2253 }
2254 if fetched.is_empty() {
2255 tracing::debug!(
2257 repo = %hf_repo,
2258 "no tokenizer assets in this repo; continuing (the backend may not need one)"
2259 );
2260 } else {
2261 tracing::debug!(repo = %hf_repo, files = ?fetched, "fetched tokenizer assets");
2262 }
2263}
2264
2265async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
2266 let api = hf_hub::api::tokio::Api::new()
2267 .map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
2268
2269 let repo = api.model(repo.to_string());
2270 let path = repo
2271 .get(filename)
2272 .await
2273 .map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
2274
2275 if dest.exists() {
2276 return Ok(());
2277 }
2278
2279 #[cfg(unix)]
2281 {
2282 if std::os::unix::fs::symlink(&path, dest).is_ok() {
2283 return Ok(());
2284 }
2285 }
2286
2287 std::fs::copy(&path, dest)
2288 .map_err(|e| InferenceError::DownloadFailed(format!("copy to {}: {e}", dest.display())))?;
2289 Ok(())
2290}
2291
2292async fn ensure_auxiliary_mlx_files(
2293 model_name: &str,
2294 hf_repo: &str,
2295 model_dir: &Path,
2296) -> Result<(), InferenceError> {
2297 if hf_repo == "mlx-community/Flux-1.lite-8B-MLX-Q4" || model_name == "Flux-1.lite-8B-MLX-Q4" {
2298 let t5_tokenizer_path = model_dir.join("tokenizer_2").join("tokenizer.json");
2299 if !t5_tokenizer_path.exists() {
2300 std::fs::create_dir_all(t5_tokenizer_path.parent().ok_or_else(|| {
2301 InferenceError::InferenceFailed("invalid tokenizer path".into())
2302 })?)?;
2303 info!(
2304 path = %t5_tokenizer_path.display(),
2305 "downloading missing Flux tokenizer_2/tokenizer.json from base model"
2306 );
2307 download_file(
2308 "Freepik/flux.1-lite-8B",
2309 "tokenizer_2/tokenizer.json",
2310 &t5_tokenizer_path,
2311 )
2312 .await?;
2313 }
2314 }
2315 Ok(())
2316}
2317
2318fn mlx_auxiliary_ready_without_download(model_name: &str, model_dir: &Path) -> bool {
2319 if model_name == "Flux-1.lite-8B-MLX-Q4" {
2320 return crate::download::cache_file_usable(
2321 &model_dir.join("tokenizer_2").join("tokenizer.json"),
2322 );
2323 }
2324 true
2325}
2326
2327fn physical_weights_ready(schema: &ModelSchema, models_dir: &Path) -> bool {
2330 physical_weights_ready_with_huggingface_hub(schema, models_dir, None)
2331}
2332
2333pub(crate) fn physical_weights_ready_with_huggingface_hub(
2334 schema: &ModelSchema,
2335 models_dir: &Path,
2336 huggingface_hub_root: Option<&Path>,
2337) -> bool {
2338 match &schema.source {
2339 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2340 let managed_dir = models_dir.join(&schema.name);
2341 if mlx_snapshot_complete(schema, &managed_dir) {
2342 return true;
2343 }
2344 let shared_snapshot = match huggingface_hub_root {
2345 Some(root) => latest_huggingface_repo_snapshot_in(
2346 &root.join(format!("models--{}", hf_repo.replace('/', "--"))),
2347 ),
2348 None => latest_huggingface_repo_snapshot(hf_repo),
2349 };
2350 shared_snapshot
2351 .as_deref()
2352 .is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
2353 }
2354 ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
2355 ModelSource::Local { .. } => {
2356 crate::download::cache_file_usable(&models_dir.join(&schema.name).join("model.gguf"))
2357 }
2358 ModelSource::WindowsSpeech {}
2362 | ModelSource::AppleFoundationModels { .. }
2363 | ModelSource::VllmMlx { .. }
2364 | ModelSource::Ollama { .. }
2365 | ModelSource::RemoteApi { .. }
2366 | ModelSource::CodexCli { .. }
2367 | ModelSource::Proprietary { .. }
2368 | ModelSource::Delegated { .. } => true,
2369 }
2370}
2371
2372#[cfg(test)]
2373fn mlx_weights_ready_at(
2374 schema: &ModelSchema,
2375 managed_dir: &Path,
2376 shared_snapshot: Option<&Path>,
2377) -> bool {
2378 mlx_snapshot_complete(schema, managed_dir)
2379 || shared_snapshot.is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
2380}
2381
2382fn mlx_snapshot_complete(schema: &ModelSchema, dir: &Path) -> bool {
2389 let is_diffusers = schema.capabilities.iter().any(|capability| {
2390 matches!(
2391 capability,
2392 ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
2393 )
2394 });
2395 let metadata_ready =
2396 is_diffusers || crate::download::cache_file_usable(&dir.join("config.json"));
2397
2398 metadata_ready
2399 && mlx_dir_has_weights(dir)
2400 && mlx_auxiliary_ready_without_download(&schema.name, dir)
2401}
2402
2403pub(crate) fn mlx_dir_has_weights(dir: &Path) -> bool {
2425 let index = dir.join("model.safetensors.index.json");
2429 if index.is_file() {
2430 return sharded_weight_files(&index).is_some_and(|required| {
2431 !required.is_empty()
2432 && required
2433 .iter()
2434 .all(|shard| crate::download::cache_file_usable(&dir.join(shard)))
2435 });
2436 }
2437 mlx_dir_has_weights_depth(dir, 0)
2438}
2439
2440pub(crate) fn missing_weight_shards(dir: &Path) -> Vec<String> {
2452 let index = dir.join("model.safetensors.index.json");
2453 if !index.is_file() {
2454 return Vec::new();
2455 }
2456 let Some(required) = sharded_weight_files(&index) else {
2457 return Vec::new();
2460 };
2461 required
2462 .into_iter()
2463 .filter(|shard| !dir.join(shard).exists())
2464 .collect()
2465}
2466
2467fn sharded_weight_files(index: &Path) -> Option<Vec<String>> {
2474 let raw = std::fs::read_to_string(index).ok()?;
2475 let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
2476 let map = parsed.get("weight_map")?.as_object()?;
2477 let mut files: Vec<String> = map
2478 .values()
2479 .filter_map(|v| v.as_str().map(str::to_string))
2480 .collect();
2481 files.sort();
2482 files.dedup();
2483 Some(files)
2484}
2485
2486fn mlx_dir_has_weights_depth(dir: &Path, depth: usize) -> bool {
2497 if depth > 4 {
2498 return false;
2499 }
2500 let Ok(rd) = std::fs::read_dir(dir) else {
2501 return false;
2502 };
2503 rd.flatten().any(|e| {
2504 let p = e.path();
2505 let is_symlink = std::fs::symlink_metadata(&p)
2506 .map(|m| m.file_type().is_symlink())
2507 .unwrap_or(true);
2508 if p.is_dir() {
2509 !is_symlink && mlx_dir_has_weights_depth(&p, depth + 1)
2510 } else {
2511 p.extension().and_then(|x| x.to_str()) == Some("safetensors")
2515 && crate::download::cache_file_usable(&p)
2516 }
2517 })
2518}
2519
2520#[allow(dead_code)] fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
2522 latest_huggingface_repo_snapshot(repo_id).is_some()
2523}
2524
2525pub(crate) fn huggingface_cache_root() -> PathBuf {
2526 std::env::var("HF_HOME")
2527 .map(PathBuf::from)
2528 .unwrap_or_else(|_| {
2529 std::env::var_os("HOME")
2530 .or_else(|| std::env::var_os("USERPROFILE"))
2531 .map(PathBuf::from)
2532 .unwrap_or_else(|| PathBuf::from("."))
2533 .join(".cache")
2534 .join("huggingface")
2535 })
2536 .join("hub")
2537}
2538
2539pub(crate) fn huggingface_repo_dir(repo_id: &str) -> PathBuf {
2540 huggingface_cache_root().join(format!("models--{}", repo_id.replace('/', "--")))
2541}
2542
2543fn resolve_huggingface_ref_snapshot(repo_dir: &Path, name: &str) -> Option<PathBuf> {
2544 let sha = std::fs::read_to_string(repo_dir.join("refs").join(name))
2545 .ok()?
2546 .trim()
2547 .to_string();
2548 if sha.is_empty() {
2549 return None;
2550 }
2551
2552 let snapshot = repo_dir.join("snapshots").join(sha);
2553 if snapshot_looks_ready(&snapshot) {
2554 Some(snapshot)
2555 } else {
2556 None
2557 }
2558}
2559
2560fn latest_huggingface_repo_snapshot(repo_id: &str) -> Option<PathBuf> {
2561 let repo_dir = huggingface_repo_dir(repo_id);
2562 latest_huggingface_repo_snapshot_in(&repo_dir)
2563}
2564
2565fn latest_huggingface_repo_snapshot_in(repo_dir: &Path) -> Option<PathBuf> {
2566 if let Some(snapshot) = resolve_huggingface_ref_snapshot(repo_dir, "main") {
2567 return Some(snapshot);
2568 }
2569
2570 let snapshots = repo_dir.join("snapshots");
2571 let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(snapshots)
2572 .ok()?
2573 .filter_map(Result::ok)
2574 .map(|e| e.path())
2575 .filter(|p| p.is_dir() && snapshot_looks_ready(p))
2576 .map(|path| {
2577 let modified = path
2578 .metadata()
2579 .and_then(|metadata| metadata.modified())
2580 .unwrap_or(SystemTime::UNIX_EPOCH);
2581 (modified, path)
2582 })
2583 .collect();
2584 candidates.sort();
2585 candidates.pop().map(|(_, path)| path)
2586}
2587
2588fn snapshot_looks_ready(path: &Path) -> bool {
2589 if path.join("config.json").exists() || path.join("model_index.json").exists() {
2590 return true;
2591 }
2592 snapshot_contains_ext(path, "safetensors")
2593}
2594
2595fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
2596 let Ok(entries) = std::fs::read_dir(root) else {
2597 return false;
2598 };
2599 entries.filter_map(Result::ok).any(|entry| {
2600 let path = entry.path();
2601 if path.is_dir() {
2602 snapshot_contains_ext(&path, ext)
2603 } else {
2604 let ext_matches = path
2605 .extension()
2606 .and_then(|value| value.to_str())
2607 .map(|value| value.eq_ignore_ascii_case(ext))
2608 .unwrap_or(false);
2609 ext_matches && crate::download::cache_file_usable(&path)
2613 }
2614 })
2615}
2616
2617const BUILTIN_CATALOG_JSON: &str = include_str!("builtin_catalog.json");
2626
2627static BUILTIN_CATALOG: std::sync::LazyLock<Vec<ModelSchema>> = std::sync::LazyLock::new(|| {
2628 serde_json::from_str(BUILTIN_CATALOG_JSON)
2629 .expect("builtin_catalog.json failed to parse — fix the JSON, not this code")
2630});
2631
2632pub(crate) fn builtin_catalog() -> Vec<ModelSchema> {
2633 let mut catalog = BUILTIN_CATALOG.clone();
2634 catalog.extend(crate::openrouter::builtin_schemas());
2635 catalog
2636}
2637
2638#[doc(hidden)]
2641pub fn builtin_catalog_with_huggingface_hub_for_testing(
2642 models_dir: &Path,
2643 huggingface_hub_root: &Path,
2644) -> Vec<ModelSchema> {
2645 let mut catalog = builtin_catalog();
2646 for schema in &mut catalog {
2647 schema.weights_ready = physical_weights_ready_with_huggingface_hub(
2648 schema,
2649 models_dir,
2650 Some(huggingface_hub_root),
2651 );
2652 }
2653 catalog
2654}
2655
2656#[cfg(test)]
2657mod tests {
2658 use crate::openrouter::StateRootScope;
2659
2660 #[test]
2668 fn a_sharded_model_missing_one_shard_is_not_installed() {
2669 let tmp = tempfile::tempdir().unwrap();
2670 let dir = tmp.path();
2671 std::fs::write(
2672 dir.join("model.safetensors.index.json"),
2673 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
2674 "b":"model-00002-of-00002.safetensors"}}"#,
2675 )
2676 .unwrap();
2677 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
2679 assert!(
2680 !mlx_dir_has_weights(dir),
2681 "a missing shard must read as not-installed, or pull silently no-ops"
2682 );
2683
2684 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
2686 assert!(
2687 mlx_dir_has_weights(dir),
2688 "a complete shard set must read as installed"
2689 );
2690 }
2691
2692 #[test]
2700 fn missing_shards_are_reported_by_name() {
2701 let tmp = tempfile::tempdir().unwrap();
2702 let dir = tmp.path();
2703 std::fs::write(
2704 dir.join("model.safetensors.index.json"),
2705 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
2706 "b":"model-00002-of-00002.safetensors"}}"#,
2707 )
2708 .unwrap();
2709 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
2710
2711 assert_eq!(
2712 missing_weight_shards(dir),
2713 vec!["model-00001-of-00002.safetensors".to_string()],
2714 "the absent shard must be named, not just counted"
2715 );
2716
2717 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
2718 assert!(
2719 missing_weight_shards(dir).is_empty(),
2720 "a complete shard set must report nothing missing"
2721 );
2722 }
2723
2724 #[test]
2728 fn missing_shards_is_empty_without_an_index() {
2729 let tmp = tempfile::tempdir().unwrap();
2730 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
2731 assert!(missing_weight_shards(tmp.path()).is_empty());
2732
2733 let bad = tempfile::tempdir().unwrap();
2736 std::fs::write(bad.path().join("model.safetensors.index.json"), b"not json").unwrap();
2737 assert!(missing_weight_shards(bad.path()).is_empty());
2738 }
2739
2740 #[test]
2742 fn a_single_file_model_still_counts_without_an_index() {
2743 let tmp = tempfile::tempdir().unwrap();
2744 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
2745 assert!(mlx_dir_has_weights(tmp.path()));
2746 }
2747
2748 #[test]
2751 fn an_unparseable_index_fails_closed() {
2752 let tmp = tempfile::tempdir().unwrap();
2753 std::fs::write(
2754 tmp.path().join("model.safetensors.index.json"),
2755 b"{not-json",
2756 )
2757 .unwrap();
2758 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
2759 assert!(
2760 !mlx_dir_has_weights(tmp.path()),
2761 "an unreadable index must not fall back to a stray weight"
2762 );
2763 }
2764
2765 use super::*;
2766 use tempfile::TempDir;
2767
2768 #[test]
2769 fn mlx_dir_has_weights_detects_completeness() {
2770 let tmp = TempDir::new().unwrap();
2771 let dir = tmp.path();
2772
2773 std::fs::write(dir.join("config.json"), "{}").unwrap();
2775 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
2776 assert!(
2777 !mlx_dir_has_weights(dir),
2778 "config-only stub must not count as installed"
2779 );
2780
2781 std::fs::write(dir.join("model.safetensors.index.json"), "{}").unwrap();
2783 assert!(!mlx_dir_has_weights(dir), "index.json alone is not weights");
2784
2785 std::fs::remove_file(dir.join("model.safetensors.index.json")).unwrap();
2787 std::fs::write(dir.join("model.safetensors"), b"\x00\x01\x02").unwrap();
2788 assert!(mlx_dir_has_weights(dir));
2789 }
2790
2791 #[test]
2792 fn mlx_dir_has_weights_handles_sharded_and_dangling_symlinks() {
2793 let sharded = TempDir::new().unwrap();
2794 std::fs::write(sharded.path().join("config.json"), "{}").unwrap();
2795 std::fs::write(
2796 sharded.path().join("model-00001-of-00002.safetensors"),
2797 b"\x00",
2798 )
2799 .unwrap();
2800 assert!(mlx_dir_has_weights(sharded.path()), "sharded shard counts");
2801
2802 #[cfg(unix)]
2805 {
2806 let dangling = TempDir::new().unwrap();
2807 std::fs::write(dangling.path().join("config.json"), "{}").unwrap();
2808 std::os::unix::fs::symlink(
2809 dangling.path().join("does-not-exist"),
2810 dangling.path().join("model.safetensors"),
2811 )
2812 .unwrap();
2813 assert!(
2814 !mlx_dir_has_weights(dangling.path()),
2815 "dangling weight symlink must count as absent"
2816 );
2817 }
2818 }
2819
2820 struct TestRegistry {
2842 registry: UnifiedRegistry,
2843 _tmp: TempDir,
2847 _environment: tokio::sync::MutexGuard<'static, ()>,
2848 }
2849
2850 impl std::ops::Deref for TestRegistry {
2851 type Target = UnifiedRegistry;
2852
2853 fn deref(&self) -> &Self::Target {
2854 &self.registry
2855 }
2856 }
2857
2858 impl std::ops::DerefMut for TestRegistry {
2859 fn deref_mut(&mut self) -> &mut Self::Target {
2860 &mut self.registry
2861 }
2862 }
2863
2864 fn test_registry() -> TestRegistry {
2865 let _environment = crate::openrouter::test_environment_scope();
2866 let tmp = TempDir::new().unwrap();
2867 let registry = UnifiedRegistry::new_with_state_root(
2868 tmp.path().to_path_buf(),
2869 tmp.path().join("models"),
2870 );
2871 TestRegistry {
2872 registry,
2873 _tmp: tmp,
2874 _environment,
2875 }
2876 }
2877
2878 fn test_generate_schema(id: &str, name: &str, source: ModelSource) -> ModelSchema {
2879 ModelSchema {
2880 id: id.into(),
2881 name: name.into(),
2882 provider: "local".into(),
2883 family: "qwen3".into(),
2884 version: "test".into(),
2885 capabilities: vec![ModelCapability::Generate],
2886 context_length: 4096,
2887 max_output_tokens: None,
2888 param_count: String::new(),
2889 quantization: None,
2890 performance: PerformanceEnvelope::default(),
2891 cost: CostModel::default(),
2892 source,
2893 tags: vec![],
2894 supported_params: vec![],
2895 public_benchmarks: vec![],
2896 trust_tier: crate::schema::TrustTier::Curated,
2897 deprecated: false,
2898 available: false,
2899 weights_ready: false,
2900 }
2901 }
2902
2903 #[test]
2908 fn model_info_carries_weights_ready_through_the_projection() {
2909 let mut schema = test_generate_schema(
2910 "mlx-community/car894-test-4bit",
2911 "car894-test-4bit",
2912 ModelSource::Mlx {
2913 hf_repo: "mlx-community/car894-test-4bit".into(),
2914 hf_weight_file: None,
2915 },
2916 );
2917
2918 schema.weights_ready = false;
2919 assert!(
2920 !ModelInfo::from(&schema).weights_ready,
2921 "a schema with no weights on disk must project weights_ready = false"
2922 );
2923
2924 schema.weights_ready = true;
2925 assert!(
2926 ModelInfo::from(&schema).weights_ready,
2927 "a schema with weights on disk must project weights_ready = true"
2928 );
2929 }
2930
2931 #[test]
2937 fn model_info_carries_downloads_weights_through_the_projection() {
2938 let mlx = test_generate_schema(
2939 "mlx-community/car894-test-4bit",
2940 "car894-test-4bit",
2941 ModelSource::Mlx {
2942 hf_repo: "mlx-community/car894-test-4bit".into(),
2943 hf_weight_file: None,
2944 },
2945 );
2946 assert!(
2947 ModelInfo::from(&mlx).downloads_weights,
2948 "an MLX entry downloads weights"
2949 );
2950
2951 for (label, source) in [
2954 ("windows speech", ModelSource::WindowsSpeech {}),
2955 (
2956 "apple foundation",
2957 ModelSource::AppleFoundationModels { use_case: None },
2958 ),
2959 ] {
2960 let schema = test_generate_schema("car894/os-model", "os-model", source);
2961 let info = ModelInfo::from(&schema);
2962 assert!(
2963 !info.downloads_weights,
2964 "{label} installs nothing, so the projection must say so"
2965 );
2966 assert!(
2967 info.is_local,
2968 "{label} is still local — which is exactly why is_local cannot stand in"
2969 );
2970 }
2971
2972 let external = test_generate_schema(
2977 "car894/external-model",
2978 "external-model",
2979 ModelSource::VllmMlx {
2980 endpoint: "http://localhost:8000".into(),
2981 model_name: "mlx-community/car894-test-4bit".into(),
2982 },
2983 );
2984 assert!(!external.is_local());
2985 assert!(external.is_remote());
2986 assert!(!external.requires_apple_silicon());
2987 let info = ModelInfo::from(&external);
2988 assert!(!info.is_local);
2989 assert!(
2990 !info.downloads_weights,
2991 "external vllm-mlx owns its weights, so CAR installs nothing"
2992 );
2993 }
2994
2995 #[test]
2996 fn model_info_classifies_only_raw_vllm_mlx_as_operator_managed_external() {
2997 for endpoint in ["http://localhost:8000", "https://models.example.invalid/v1"] {
2998 let schema = test_generate_schema(
2999 "external/model",
3000 "external-model",
3001 ModelSource::VllmMlx {
3002 endpoint: endpoint.into(),
3003 model_name: "mlx-community/external-model".into(),
3004 },
3005 );
3006 let info = ModelInfo::from(&schema);
3007 assert!(info.operator_managed_external_runtime);
3008 assert_eq!(
3009 serde_json::to_value(info).unwrap()["operator_managed_external_runtime"],
3010 true
3011 );
3012 }
3013
3014 for source in [
3015 ModelSource::RemoteApi {
3016 endpoint: "https://cloud.example.invalid/v1".into(),
3017 api_key_env: "CAR_TEST_KEY".into(),
3018 api_key_envs: vec![],
3019 api_version: None,
3020 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3021 },
3022 ModelSource::ManagedVllmMlx {
3023 hf_repo: "mlx-community/car-owned-model".into(),
3024 hf_weight_file: None,
3025 },
3026 ] {
3027 assert!(
3028 !ModelInfo::from(&test_generate_schema(
3029 "not-external/model",
3030 "not-external-model",
3031 source,
3032 ))
3033 .operator_managed_external_runtime
3034 );
3035 }
3036 }
3037
3038 #[test]
3045 fn fresh_machine_mlx_entry_is_available_but_not_weights_ready() {
3046 let mut reg = test_registry();
3047 let id = "mlx-community/car894-fresh-4bit";
3048 reg.register(test_generate_schema(
3049 id,
3050 "car894-fresh-4bit",
3051 ModelSource::Mlx {
3052 hf_repo: "mlx-community/car894-fresh-4bit".into(),
3053 hf_weight_file: None,
3054 },
3055 ));
3056
3057 let registered = reg
3058 .get(id)
3059 .expect("the model just registered must be in the registry");
3060 let info = ModelInfo::from(registered);
3061
3062 assert!(
3064 !registered.weights_ready,
3065 "an empty models dir means no weights on disk"
3066 );
3067 assert!(
3068 !info.weights_ready,
3069 "the CLI-facing projection must report the same: nothing installed"
3070 );
3071
3072 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3073 {
3074 assert!(
3077 registered.available,
3078 "a declared hf_repo makes an MLX entry runnable before download (#164)"
3079 );
3080 assert!(
3081 info.available,
3082 "the projection must keep reporting it as runnable"
3083 );
3084 }
3085 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3086 {
3087 assert!(
3090 !registered.available,
3091 "MLX cannot execute on this target, so it must not be runnable"
3092 );
3093 assert!(!info.available);
3094 }
3095 }
3096
3097 fn write_complete_mlx_snapshot(dir: &Path) {
3098 std::fs::create_dir_all(dir).unwrap();
3099 std::fs::write(dir.join("config.json"), b"{}").unwrap();
3100 std::fs::write(dir.join("tokenizer.json"), b"{}").unwrap();
3101 std::fs::write(
3102 dir.join("model.safetensors.index.json"),
3103 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
3104 )
3105 .unwrap();
3106 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"one").unwrap();
3107 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
3108 }
3109
3110 #[test]
3111 fn complete_managed_and_shared_mlx_snapshots_are_physically_ready() {
3112 let schema = test_generate_schema(
3113 "mlx/qwen3-4b:4bit",
3114 "Qwen3-4B-MLX",
3115 ModelSource::Mlx {
3116 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
3117 hf_weight_file: None,
3118 },
3119 );
3120 let root = tempfile::tempdir().unwrap();
3121 let managed = root.path().join("managed");
3122 let shared = root.path().join("shared");
3123
3124 write_complete_mlx_snapshot(&managed);
3125 assert!(mlx_weights_ready_at(&schema, &managed, None));
3126
3127 std::fs::remove_dir_all(&managed).unwrap();
3128 write_complete_mlx_snapshot(&shared);
3129 assert!(mlx_weights_ready_at(&schema, &managed, Some(&shared)));
3130 }
3131
3132 #[test]
3133 fn zero_byte_gguf_is_not_physically_ready() {
3134 let schema = test_generate_schema(
3135 "qwen/qwen3-4b:q4_k_m",
3136 "Qwen3-4B",
3137 ModelSource::Local {
3138 hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
3139 hf_filename: "model.gguf".into(),
3140 tokenizer_repo: "Qwen/Qwen3-4B".into(),
3141 },
3142 );
3143 let root = tempfile::tempdir().unwrap();
3144 let model_dir = root.path().join(&schema.name);
3145 std::fs::create_dir_all(&model_dir).unwrap();
3146 std::fs::write(model_dir.join("model.gguf"), b"").unwrap();
3147
3148 assert!(!physical_weights_ready(&schema, root.path()));
3149 std::fs::write(model_dir.join("model.gguf"), b"gguf").unwrap();
3150 assert!(physical_weights_ready(&schema, root.path()));
3151 }
3152
3153 #[test]
3154 fn shared_mlx_snapshot_missing_an_indexed_shard_is_not_physically_ready() {
3155 let schema = test_generate_schema(
3156 "mlx/qwen3-8b:4bit",
3157 "Qwen3-8B-MLX",
3158 ModelSource::Mlx {
3159 hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
3160 hf_weight_file: None,
3161 },
3162 );
3163 let root = tempfile::tempdir().unwrap();
3164 let managed = root.path().join("managed");
3165 let shared = root.path().join("shared");
3166 write_complete_mlx_snapshot(&shared);
3167 std::fs::remove_file(shared.join("model-00001-of-00002.safetensors")).unwrap();
3168
3169 assert!(!mlx_weights_ready_at(&schema, &managed, Some(&shared)));
3170 }
3171
3172 #[test]
3204 fn a_gateway_that_reports_no_upstream_stops_being_advertised() {
3205 let _guard = crate::openrouter::test_environment_scope();
3206 let _home = StateRootScope::new();
3209 crate::openrouter::clear_gateway_unconfigured();
3210
3211 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
3212 .into_iter()
3213 .filter(|s| crate::openrouter::is_curated_managed_gateway_alias(&s.id))
3214 .collect();
3215 assert!(
3216 !managed.is_empty(),
3217 "precondition: the curated catalog must still carry managed aliases"
3218 );
3219
3220 let availability_of = |schema: &ModelSchema| match &schema.source {
3221 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
3222 &schema.id,
3223 &schema.provider,
3224 provider,
3225 auth,
3226 true,
3228 &std::collections::HashMap::new(),
3229 ),
3230 other => panic!("managed aliases must be Proprietary, got {other:?}"),
3231 };
3232
3233 assert!(
3234 managed.iter().all(availability_of),
3235 "precondition: an authenticated session advertises these today"
3236 );
3237
3238 crate::openrouter::note_gateway_unconfigured();
3239 assert!(
3240 managed.iter().all(|s| !availability_of(s)),
3241 "after the gateway says it has no OpenRouter upstream, every alias in \
3242 the namespace must report unavailable — that claim is what cost the \
3243 benchmark sweep in #786"
3244 );
3245
3246 crate::openrouter::clear_gateway_unconfigured();
3250 assert!(
3251 managed.iter().all(availability_of),
3252 "the suppression must be recoverable, not a one-way latch"
3253 );
3254 }
3255
3256 #[test]
3270 fn constructing_a_registry_with_a_live_session_leaves_the_gateway_observation_alone() {
3271 let _guard = crate::openrouter::test_environment_scope();
3272 let home = StateRootScope::new();
3273
3274 crate::openrouter::note_gateway_unconfigured();
3275 let _registry = UnifiedRegistry::new_with_session(
3276 home.path().to_path_buf(),
3277 home.path().join("models"),
3278 None,
3279 SessionProbe::Fixed(true),
3280 );
3281
3282 let observed = crate::openrouter::gateway_unconfigured();
3283 let persisted = crate::openrouter::gateway_state_path().exists();
3284
3285 crate::openrouter::clear_gateway_unconfigured();
3286
3287 assert!(
3288 observed,
3289 "a signed-in session has no reason to forget what the gateway said"
3290 );
3291 assert!(
3292 persisted,
3293 "the durable half of the observation must survive construction too"
3294 );
3295 }
3296
3297 #[test]
3305 fn constructing_a_registry_with_no_session_still_forgets_the_gateway_observation() {
3306 let _guard = crate::openrouter::test_environment_scope();
3307 let home = StateRootScope::new();
3308
3309 crate::openrouter::note_gateway_unconfigured();
3310 let recorded = crate::openrouter::gateway_unconfigured();
3311 let _registry = UnifiedRegistry::new_with_session(
3312 home.path().to_path_buf(),
3313 home.path().join("models"),
3314 None,
3315 SessionProbe::Fixed(false),
3316 );
3317
3318 let observed = crate::openrouter::gateway_unconfigured();
3319 let persisted = crate::openrouter::gateway_state_path().exists();
3320
3321 crate::openrouter::clear_gateway_unconfigured();
3322
3323 assert!(
3324 recorded,
3325 "precondition: the observation is on record before construction"
3326 );
3327 assert!(
3328 !observed,
3329 "sign-out must still discard the session-scoped verdict (#786)"
3330 );
3331 assert!(
3332 !persisted,
3333 "and the durable copy with it — otherwise the next sign-in inherits it from disk"
3334 );
3335 }
3336
3337 #[test]
3354 fn an_ordinary_test_registry_does_not_disturb_a_separately_set_observation() {
3355 let _guard = crate::openrouter::test_environment_scope();
3356 let home = StateRootScope::new();
3357
3358 crate::openrouter::note_gateway_unconfigured();
3359 let _registry = UnifiedRegistry::new_with_state_root(
3360 home.path().to_path_buf(),
3361 home.path().join("models"),
3362 );
3363
3364 let observed = crate::openrouter::gateway_unconfigured();
3365
3366 crate::openrouter::clear_gateway_unconfigured();
3367
3368 assert!(
3369 observed,
3370 "constructing a registry is not a statement about the session, so it \
3371 must not erase an observation another test just recorded (#986)"
3372 );
3373 assert!(
3374 !SessionProbe::Inert.may_forget_session_evidence(),
3375 "the `cfg(test)` construction default must be a probe that answers \
3376 the session question without acting on it — this is the half of \
3377 the guarantee that does not depend on whether the runner happens \
3378 to be signed in"
3379 );
3380 }
3381
3382 #[test]
3395 fn a_rejected_credential_stops_the_managed_lane_being_advertised() {
3396 let _guard = crate::openrouter::test_environment_scope();
3397 let _home = StateRootScope::new();
3398 crate::parslee_credential::clear_credential_rejected();
3399
3400 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
3401 .into_iter()
3402 .filter(|s| s.provider == "parslee")
3403 .collect();
3404 assert!(
3405 !managed.is_empty(),
3406 "precondition: the curated catalog must still carry parslee rows"
3407 );
3408
3409 let availability_of = |schema: &ModelSchema| match &schema.source {
3410 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
3411 &schema.id,
3412 &schema.provider,
3413 provider,
3414 auth,
3415 true,
3417 &std::collections::HashMap::new(),
3418 ),
3419 other => panic!("parslee rows must be Proprietary, got {other:?}"),
3420 };
3421
3422 assert!(
3423 managed.iter().all(availability_of),
3424 "precondition: an authenticated session advertises these today"
3425 );
3426
3427 crate::parslee_credential::note_credential_rejected();
3428 assert!(
3429 managed.iter().all(|s| !availability_of(s)),
3430 "after the server rejects the credential, EVERY parslee row must \
3431 report unavailable — unlike the gateway verdict this is not scoped \
3432 to the curated OpenRouter aliases, because a dead credential kills \
3433 the whole namespace"
3434 );
3435
3436 crate::parslee_credential::clear_credential_rejected();
3439 assert!(
3440 managed.iter().all(availability_of),
3441 "the suppression must lift once the credential works again"
3442 );
3443 }
3444
3445 #[test]
3446 fn refresh_availability_probes_each_credential_once_not_per_model() {
3447 let _environment = crate::openrouter::test_environment_scope();
3460 let tmp = TempDir::new().unwrap();
3461 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
3462 for i in 0..25 {
3463 let mut schema = test_generate_schema(
3464 &format!("openrouter/model-{i}"),
3465 &format!("model-{i}"),
3466 ModelSource::RemoteApi {
3467 protocol: crate::schema::ApiProtocol::OpenRouter,
3468 endpoint: "https://openrouter.ai/api/v1".into(),
3469 api_key_env: "OPENROUTER_API_KEY".into(),
3470 api_key_envs: vec![],
3471 api_version: None,
3472 },
3473 );
3474 schema.provider = "openrouter".into();
3475 registry.register(schema);
3476 }
3477
3478 crate::openrouter::reset_credential_source_call_count();
3479 registry.refresh_availability();
3480 let calls = crate::openrouter::credential_source_call_count();
3481
3482 assert_eq!(
3483 calls, 1,
3484 "refresh_availability probed the OpenRouter credential {calls} times for 25 models; \
3485 it must resolve each distinct credential once per refresh, not once per model"
3486 );
3487 }
3488
3489 #[test]
3495 fn a_vllm_mlx_pull_targets_the_shared_huggingface_cache() {
3496 let repo = "mlx-community/Qwen3.8-27B-4bit";
3497 let dir = huggingface_repo_dir(repo);
3498 assert!(
3499 dir.ends_with("models--mlx-community--Qwen3.8-27B-4bit"),
3500 "got {}",
3501 dir.display()
3502 );
3503 assert!(
3504 dir.parent().is_some_and(|p| p.ends_with("hub")),
3505 "must live under the HF cache's hub/ root, got {}",
3506 dir.display()
3507 );
3508 }
3509
3510 #[test]
3513 fn external_vllm_mlx_does_not_become_managed_from_a_loopback_endpoint() {
3514 let _environment = crate::openrouter::test_environment_scope();
3527 let tmp = TempDir::new().unwrap();
3528 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
3529 let schema = test_generate_schema(
3530 "vllm-mlx/arch-the-rust-backend-cannot-load",
3531 "external-only-model",
3532 ModelSource::VllmMlx {
3533 endpoint: "http://localhost:8000".into(),
3534 model_name: "mlx-community/Qwen3.8-27B-4bit".into(),
3535 },
3536 );
3537 registry.register(schema);
3538
3539 assert!(
3541 std::env::var("VLLM_MLX_ENDPOINT").is_err(),
3542 "test precondition: VLLM_MLX_ENDPOINT must be unset"
3543 );
3544 registry.refresh_availability();
3545
3546 let model = registry
3547 .get("vllm-mlx/arch-the-rust-backend-cannot-load")
3548 .expect("registered model should be present");
3549 assert!(
3550 !model.available,
3551 "an external vllm-mlx row remains external even on loopback; only an \
3552 explicit ManagedVllmMlx source may use CAR's runtime"
3553 );
3554 }
3555
3556 #[test]
3557 fn user_config_load_and_save_force_community_trust() {
3558 let tmp = TempDir::new().unwrap();
3559 let models_dir = tmp.path().join("models");
3560 let config_path = tmp.path().join("models.json");
3561 let schema = test_generate_schema(
3562 "user/test-model",
3563 "user-test-model",
3564 ModelSource::RemoteApi {
3565 endpoint: "https://attacker.invalid/v1/chat/completions".into(),
3566 api_key_env: "CAR_USER_MODEL_TEST_KEY".into(),
3567 api_key_envs: vec![],
3568 api_version: None,
3569 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3570 },
3571 );
3572 let mut omitted_tier = serde_json::to_value(schema.clone()).unwrap();
3573 omitted_tier.as_object_mut().unwrap().remove("trust_tier");
3574 std::fs::write(
3575 &config_path,
3576 serde_json::to_vec_pretty(&vec![omitted_tier]).unwrap(),
3577 )
3578 .unwrap();
3579
3580 let mut loaded = UnifiedRegistry::new_empty(models_dir.clone());
3581 loaded.load_user_config().unwrap();
3582 assert_eq!(
3583 loaded.get("user/test-model").unwrap().trust_tier,
3584 crate::schema::TrustTier::Community
3585 );
3586
3587 let mut persisted = UnifiedRegistry::new_empty(models_dir);
3588 persisted.register_user_model(schema);
3589 persisted.save_user_config().unwrap();
3590 let saved: Vec<ModelSchema> =
3591 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
3592 assert_eq!(saved.len(), 1);
3593 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
3594 }
3595
3596 #[test]
3597 fn persisted_user_model_cannot_shadow_managed_openrouter_alias() {
3598 let _environment = crate::openrouter::test_environment_scope();
3603 let tmp = TempDir::new().unwrap();
3604 let models_dir = tmp.path().join("models");
3605 let config_path = tmp.path().join("models.json");
3606 let mut shadow = crate::openrouter::curated_schemas()
3607 .into_iter()
3608 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
3609 .unwrap();
3610 shadow.provider = "attacker".into();
3611 std::fs::write(
3612 &config_path,
3613 serde_json::to_vec_pretty(&vec![shadow]).unwrap(),
3614 )
3615 .unwrap();
3616
3617 let registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
3618 let actual = registry
3619 .get("parslee/openrouter/frontier-general")
3620 .expect("compiled managed alias must remain present");
3621 assert_eq!(actual.provider, "parslee");
3622 assert_eq!(
3623 crate::openrouter::canonical_managed_gateway_selector(actual),
3624 Some("parslee/openrouter/frontier-general")
3625 );
3626 }
3627
3628 #[test]
3629 fn user_config_persistence_excludes_signed_rows_and_keeps_builtin_tagged_user_rows() {
3630 let _environment = crate::openrouter::test_environment_scope();
3635 let tmp = TempDir::new().unwrap();
3636 let models_dir = tmp.path().join("models");
3637 let config_path = tmp.path().join("models.json");
3638
3639 let signed = test_generate_schema(
3640 "signed/catalog-only",
3641 "signed-catalog-only",
3642 ModelSource::RemoteApi {
3643 endpoint: "https://catalog.example/v1".into(),
3644 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
3645 api_key_envs: vec![],
3646 api_version: None,
3647 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3648 },
3649 );
3650 assert!(!signed.tags.iter().any(|tag| tag == "builtin"));
3651 let (verified, public_key) = crate::catalog::signed_test_catalog(
3652 crate::catalog::CatalogDoc {
3653 version: 81,
3654 models: vec![signed],
3655 },
3656 81,
3657 );
3658 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
3659
3660 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
3661 tmp.path().to_path_buf(),
3662 models_dir.clone(),
3663 Some(public_key.as_str()),
3664 );
3665 let mut user = test_generate_schema(
3666 "user/builtin-tagged",
3667 "user-builtin-tagged",
3668 ModelSource::RemoteApi {
3669 endpoint: "https://user.example/v1".into(),
3670 api_key_env: "USER_MODEL_TEST_KEY".into(),
3671 api_key_envs: vec![],
3672 api_version: None,
3673 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3674 },
3675 );
3676 user.tags.push("builtin".into());
3677 registry.register_user_model(user);
3678 registry.save_user_config().unwrap();
3679
3680 let saved: Vec<ModelSchema> =
3681 serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
3682 assert_eq!(
3683 saved
3684 .iter()
3685 .map(|model| model.id.as_str())
3686 .collect::<Vec<_>>(),
3687 vec!["user/builtin-tagged"],
3688 "models.json must contain only explicitly user-registered rows"
3689 );
3690 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
3691
3692 let mut restarted = UnifiedRegistry::new_with_catalog_public_key(
3693 tmp.path().to_path_buf(),
3694 models_dir,
3695 Some(public_key.as_str()),
3696 );
3697 assert_eq!(
3698 restarted.get("signed/catalog-only").unwrap().trust_tier,
3699 crate::schema::TrustTier::Curated,
3700 "user persistence must not demote an unrelated signed catalog row"
3701 );
3702 assert_eq!(
3703 restarted.get("user/builtin-tagged").unwrap().trust_tier,
3704 crate::schema::TrustTier::Community
3705 );
3706 let mut signed_shadow = restarted.get("signed/catalog-only").unwrap().clone();
3707 signed_shadow.name = "user-shadow-of-signed-row".into();
3708 restarted.register_user_model(signed_shadow);
3709 assert_eq!(
3710 restarted.get("signed/catalog-only").unwrap().name,
3711 "signed-catalog-only",
3712 "a user row must not shadow a signature-verified project exact id"
3713 );
3714 }
3715
3716 #[test]
3717 fn empty_user_config_save_clears_stale_rows() {
3718 let tmp = TempDir::new().unwrap();
3719 let models_dir = tmp.path().join("models");
3720 let config_path = tmp.path().join("models.json");
3721 let stale = test_generate_schema(
3722 "user/stale",
3723 "stale",
3724 ModelSource::RemoteApi {
3725 endpoint: "https://stale.example/v1".into(),
3726 api_key_env: "STALE_USER_MODEL_TEST_KEY".into(),
3727 api_key_envs: vec![],
3728 api_version: None,
3729 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3730 },
3731 );
3732 std::fs::write(
3733 &config_path,
3734 serde_json::to_vec_pretty(&vec![stale]).unwrap(),
3735 )
3736 .unwrap();
3737
3738 UnifiedRegistry::new_empty(models_dir)
3739 .save_user_config()
3740 .unwrap();
3741
3742 let saved: Vec<ModelSchema> =
3743 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
3744 assert!(
3745 saved.is_empty(),
3746 "saving an empty user set must overwrite stale models.json rows"
3747 );
3748 }
3749
3750 #[test]
3751 fn unregister_then_save_removes_the_user_row_from_disk() {
3752 let tmp = TempDir::new().unwrap();
3753 let models_dir = tmp.path().join("models");
3754 let config_path = tmp.path().join("models.json");
3755 let mut registry = UnifiedRegistry::new_empty(models_dir);
3756 registry.register_project_model(test_generate_schema(
3757 "signed/not-user-removable",
3758 "not-user-removable",
3759 ModelSource::RemoteApi {
3760 endpoint: "https://catalog.example/v1".into(),
3761 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
3762 api_key_envs: vec![],
3763 api_version: None,
3764 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3765 },
3766 ));
3767 assert!(
3768 registry
3769 .unregister_user_model("signed/not-user-removable")
3770 .is_none(),
3771 "the user boundary cannot unregister an untracked catalog row"
3772 );
3773 assert!(registry.get("signed/not-user-removable").is_some());
3774 registry.register_user_model(test_generate_schema(
3775 "user/removable",
3776 "removable",
3777 ModelSource::RemoteApi {
3778 endpoint: "https://user.example/v1".into(),
3779 api_key_env: "REMOVABLE_USER_MODEL_TEST_KEY".into(),
3780 api_key_envs: vec![],
3781 api_version: None,
3782 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3783 },
3784 ));
3785 registry.save_user_config().unwrap();
3786 assert!(registry.unregister_user_model("user/removable").is_some());
3787 registry.save_user_config().unwrap();
3788
3789 let saved: Vec<ModelSchema> =
3790 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
3791 assert!(saved.is_empty());
3792 }
3793
3794 #[test]
3806 fn a_registration_written_under_car_home_is_the_file_the_registry_reads() {
3807 let _environment = crate::openrouter::test_environment_scope();
3808 let prior = std::env::var_os(car_home::ENV_VAR);
3809
3810 let state_root = TempDir::new().unwrap();
3811 let weights = TempDir::new().unwrap();
3814 let models_dir = weights.path().join("models");
3815 std::fs::create_dir_all(&models_dir).unwrap();
3816
3817 unsafe { std::env::set_var(car_home::ENV_VAR, state_root.path()) };
3818
3819 let write_path = user_config_path().expect("CAR_HOME must resolve a models.json path");
3821 assert_eq!(write_path, state_root.path().join(USER_MODELS_FILE));
3822 let registered = test_generate_schema(
3823 "user/relocated-daemon-model",
3824 "relocated-daemon-model",
3825 ModelSource::RemoteApi {
3826 endpoint: "https://relocated.example/v1".into(),
3827 api_key_env: "RELOCATED_DAEMON_MODEL_TEST_KEY".into(),
3828 api_key_envs: vec![],
3829 api_version: None,
3830 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3831 },
3832 );
3833 std::fs::write(
3834 &write_path,
3835 serde_json::to_vec_pretty(&vec![registered]).unwrap(),
3836 )
3837 .unwrap();
3838
3839 let registry = UnifiedRegistry::new(models_dir.clone());
3841
3842 match prior {
3843 Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
3844 None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
3845 }
3846
3847 assert!(
3848 registry.get("user/relocated-daemon-model").is_some(),
3849 "the registry must load the models.json that `models.register` wrote; \
3850 it looked at {} instead",
3851 registry.user_config_path.display(),
3852 );
3853 assert_eq!(registry.user_config_path, write_path);
3854 assert!(
3855 !weights.path().join(USER_MODELS_FILE).exists(),
3856 "nothing may be written beside the shared weights cache",
3857 );
3858 }
3859
3860 #[test]
3861 fn ready_without_download_is_strict_for_local_model_files() {
3862 let tmp = TempDir::new().unwrap();
3863 let models = tmp.path().join("models");
3864 let mut reg = UnifiedRegistry::new_empty(models.clone());
3865 reg.register(test_generate_schema(
3866 "local/test",
3867 "TestLocal",
3868 ModelSource::Local {
3869 hf_repo: "example/repo".into(),
3870 hf_filename: "model.gguf".into(),
3871 tokenizer_repo: "example/repo".into(),
3872 },
3873 ));
3874
3875 assert_eq!(reg.ready_without_download("local/test"), Some(false));
3876
3877 let dir = models.join("TestLocal");
3878 std::fs::create_dir_all(&dir).unwrap();
3879 std::fs::write(dir.join("model.gguf"), b"weights").unwrap();
3880 assert_eq!(
3881 reg.ready_without_download("local/test"),
3882 Some(false),
3883 "tokenizer is required too"
3884 );
3885 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
3886 assert_eq!(reg.ready_without_download("local/test"), Some(true));
3887 }
3888
3889 #[test]
3890 fn ready_without_download_rejects_mlx_config_only_stub() {
3891 let tmp = TempDir::new().unwrap();
3892 let models = tmp.path().join("models");
3893 let mut reg = UnifiedRegistry::new_empty(models.clone());
3894 reg.register(test_generate_schema(
3895 "mlx/test",
3896 "TestMlx",
3897 ModelSource::Mlx {
3898 hf_repo: "example/repo".into(),
3899 hf_weight_file: None,
3900 },
3901 ));
3902
3903 let dir = models.join("TestMlx");
3904 std::fs::create_dir_all(&dir).unwrap();
3905 std::fs::write(dir.join("config.json"), "{}").unwrap();
3906 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
3907 assert_eq!(
3908 reg.ready_without_download("mlx/test"),
3909 Some(false),
3910 "config/tokenizer stubs must not start assistant inference"
3911 );
3912
3913 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
3914 assert_eq!(reg.ready_without_download("mlx/test"), Some(true));
3915 }
3916
3917 fn write_mlx_dir(root: &Path, name: &str, model_type: &str) {
3918 let dir = root.join(name);
3919 std::fs::create_dir_all(&dir).unwrap();
3920 std::fs::write(
3921 dir.join("config.json"),
3922 serde_json::json!({
3923 "model_type": model_type,
3924 "max_position_embeddings": 40_960,
3925 "quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" },
3926 })
3927 .to_string(),
3928 )
3929 .unwrap();
3930 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
3931 }
3932
3933 #[test]
3934 fn synthesize_local_schema_classifies_by_name_and_arch() {
3935 let tmp = TempDir::new().unwrap();
3936 let root = tmp.path();
3937
3938 write_mlx_dir(root, "MyCustom-Qwen3-7B", "qwen3");
3939 let gen = synthesize_local_schema("MyCustom-Qwen3-7B", &root.join("MyCustom-Qwen3-7B"))
3940 .expect("text LLM should be recognized");
3941 assert_eq!(
3942 gen.capabilities,
3943 vec![
3944 ModelCapability::Generate,
3945 ModelCapability::Code,
3946 ModelCapability::Reasoning
3947 ]
3948 );
3949 assert_eq!(gen.context_length, 40_960);
3950 assert_eq!(gen.provider, "local");
3951 assert!(matches!(gen.source, ModelSource::Mlx { .. }));
3952
3953 write_mlx_dir(root, "Some-Embedding-0.6B", "qwen3");
3954 let emb = synthesize_local_schema("Some-Embedding-0.6B", &root.join("Some-Embedding-0.6B"))
3955 .expect("embedding model recognized");
3956 assert_eq!(emb.capabilities, vec![ModelCapability::Embed]);
3957
3958 write_mlx_dir(root, "Mystery-Net", "some_unknown_arch");
3960 assert!(synthesize_local_schema("Mystery-Net", &root.join("Mystery-Net")).is_none());
3961
3962 write_mlx_dir(root, "silero-vad-v6-mlx", "qwen3");
3964 assert!(
3965 synthesize_local_schema("silero-vad-v6-mlx", &root.join("silero-vad-v6-mlx")).is_none()
3966 );
3967
3968 std::fs::create_dir_all(root.join("empty")).unwrap();
3970 assert!(synthesize_local_schema("empty", &root.join("empty")).is_none());
3971 }
3972
3973 #[test]
3979 fn a_scanned_gguf_directory_diagnoses_instead_of_downloading_from_nowhere() {
3980 let _environment = crate::openrouter::test_environment_scope();
3981 let tmp = TempDir::new().unwrap();
3982 let models = tmp.path().join("models");
3983 let dir = models.join("Dropped-In-Llama");
3984 std::fs::create_dir_all(&dir).unwrap();
3985 std::fs::write(dir.join("Llama-3-8B-Q4_K_M.gguf"), b"weights").unwrap();
3987
3988 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
3991 let err = tokio::runtime::Runtime::new()
3992 .unwrap()
3993 .block_on(reg.ensure_local("Dropped-In-Llama"))
3994 .expect_err("an unloadable layout must not report success");
3995 let err = err.to_string();
3996
3997 assert!(err.contains("model.gguf"), "must name what it reads: {err}");
3998 assert!(
3999 err.contains("tokenizer.json"),
4000 "must name the missing tokenizer too: {err}"
4001 );
4002 assert!(
4003 !err.contains("huggingface.co//"),
4004 "must not have tried to fetch from an empty repo: {err}"
4005 );
4006 }
4007
4008 #[test]
4009 fn discovery_registers_uncatalogued_local_model() {
4010 let _environment = crate::openrouter::test_environment_scope();
4015 let tmp = TempDir::new().unwrap();
4016 let models = tmp.path().join("models");
4017 std::fs::create_dir_all(&models).unwrap();
4018 write_mlx_dir(&models, "Totally-Custom-Llama-3B", "llama");
4019
4020 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
4021 let found = reg
4022 .list()
4023 .into_iter()
4024 .find(|m| m.name == "Totally-Custom-Llama-3B");
4025 assert!(
4026 found.is_some(),
4027 "uncatalogued on-disk model should be registered"
4028 );
4029 assert!(found.unwrap().tags.iter().any(|t| t == "auto-discovered"));
4030 }
4031
4032 #[test]
4033 fn signed_catalog_cannot_shadow_builtin_exact_id() {
4034 let _environment = crate::openrouter::test_environment_scope();
4039 let tmp = TempDir::new().unwrap();
4043 let models_dir = tmp.path().join("models");
4044
4045 let builtin = builtin_catalog();
4046 let mut overriding = builtin.first().expect("a built-in model").clone();
4047 let target_id = overriding.id.clone();
4048 overriding.name = "REPLACED-BY-CATALOG".into();
4049
4050 let (verified, public_key) = crate::catalog::signed_test_catalog(
4051 crate::catalog::CatalogDoc {
4052 version: 1,
4053 models: vec![overriding],
4054 },
4055 51,
4056 );
4057 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
4058
4059 let reg = UnifiedRegistry::new_with_catalog_public_key(
4061 tmp.path().to_path_buf(),
4062 models_dir,
4063 Some(public_key.as_str()),
4064 );
4065 assert_eq!(
4066 reg.get(&target_id).map(|m| m.name.as_str()),
4067 Some(builtin.first().unwrap().name.as_str()),
4068 "a signed cache row must not replace a builtin exact id"
4069 );
4070 }
4071
4072 #[test]
4073 fn user_model_cannot_shadow_project_owned_exact_id() {
4074 let mut reg = test_registry();
4075 let original = builtin_catalog().first().expect("a builtin").clone();
4076 let mut forged = original.clone();
4077 forged.name = "USER-SHADOW".into();
4078
4079 reg.register_user_model(forged);
4080
4081 assert_eq!(
4082 reg.get(&original.id).map(|model| model.name.as_str()),
4083 Some(original.name.as_str()),
4084 "a user row must not replace a project-owned exact id"
4085 );
4086 }
4087
4088 #[test]
4089 fn legacy_unsigned_catalog_cache_cannot_replace_builtin() {
4090 let _environment = crate::openrouter::test_environment_scope();
4095 let tmp = TempDir::new().unwrap();
4096 let models_dir = tmp.path().join("models");
4097 let builtin = builtin_catalog();
4098 let original = builtin.first().expect("a built-in model");
4099 let mut forged = original.clone();
4100 forged.name = "FORGED-UNSIGNED-CATALOG".into();
4101 let path = crate::catalog::cache_path(tmp.path());
4102 std::fs::write(
4103 &path,
4104 serde_json::to_vec_pretty(&crate::catalog::CatalogDoc {
4105 version: u64::MAX,
4106 models: vec![forged],
4107 })
4108 .unwrap(),
4109 )
4110 .unwrap();
4111
4112 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
4113 assert_eq!(
4114 reg.get(&original.id).map(|model| model.name.as_str()),
4115 Some(original.name.as_str()),
4116 "legacy unsigned cache JSON must fail closed and preserve the built-in"
4117 );
4118 }
4119
4120 #[test]
4121 fn tampered_signed_managed_row_preserves_builtin() {
4122 let _environment = crate::openrouter::test_environment_scope();
4127 let tmp = TempDir::new().unwrap();
4128 let models_dir = tmp.path().join("models");
4129 let original = builtin_catalog()
4130 .into_iter()
4131 .find(|model| model.id == "parslee/openrouter/frontier-general")
4132 .expect("managed frontier alias");
4133 let mut forged = original.clone();
4134 forged.name = "SIGNED-THEN-TAMPERED-MANAGED".into();
4135 let (verified, public_key) = crate::catalog::signed_test_catalog(
4136 crate::catalog::CatalogDoc {
4137 version: 9,
4138 models: vec![forged],
4139 },
4140 52,
4141 );
4142 let path = crate::catalog::cache_path(tmp.path());
4143 crate::catalog::save_verified(&path, &verified).unwrap();
4144 let cache = std::fs::read_to_string(&path)
4145 .unwrap()
4146 .replace("SIGNED-THEN-TAMPERED-MANAGED", "ATTACKER-MUTATION");
4147 std::fs::write(&path, cache).unwrap();
4148
4149 let reg = UnifiedRegistry::new_with_catalog_public_key(
4150 tmp.path().to_path_buf(),
4151 models_dir,
4152 Some(public_key.as_str()),
4153 );
4154 assert_eq!(
4155 reg.get(&original.id).map(|model| model.name.as_str()),
4156 Some(original.name.as_str()),
4157 "a tampered same-id managed row must fail verification and preserve the builtin"
4158 );
4159 }
4160
4161 #[test]
4162 fn builtin_catalog_loads() {
4163 let reg = test_registry();
4164 let all = reg.list();
4165 assert_eq!(all.len(), builtin_catalog().len());
4166 }
4167
4168 #[test]
4169 fn shipped_supervised_vllm_models_use_the_managed_source_contract() {
4170 let managed = builtin_catalog()
4171 .into_iter()
4172 .filter(|model| model.id.starts_with("vllm-mlx/"))
4173 .collect::<Vec<_>>();
4174 assert_eq!(managed.len(), 8);
4175 assert!(managed.iter().all(ModelSchema::is_car_managed_vllm_mlx));
4176 assert!(managed.iter().all(ModelSchema::downloads_weights));
4177 }
4178
4179 #[test]
4192 fn mlx_vlm_models_reflect_runtime_availability() {
4193 let reg = test_registry();
4194 let mlx_vlm_models: Vec<&ModelSchema> = reg
4195 .list()
4196 .into_iter()
4197 .filter(|m| m.tags.iter().any(|t| t == "requires-mlx-vlm"))
4198 .collect();
4199 assert!(
4200 !mlx_vlm_models.is_empty(),
4201 "catalog should contain at least one model tagged \
4202 `requires-mlx-vlm` — otherwise this regression has \
4203 nothing to guard"
4204 );
4205
4206 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4207 let expected = crate::backend::mlx_vlm_cli::is_available();
4208 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4209 let expected = false;
4210
4211 for m in mlx_vlm_models {
4212 assert_eq!(
4213 m.available, expected,
4214 "model {} `available` field should reflect \
4215 mlx_vlm CLI presence (expected {expected}, got {})",
4216 m.id, m.available
4217 );
4218 }
4219 }
4220
4221 #[test]
4233 fn mlx_models_unavailable_on_non_mlx_targets() {
4234 let reg = test_registry();
4235 let mlx_models: Vec<&ModelSchema> = reg
4236 .list()
4237 .into_iter()
4238 .filter(|m| {
4239 m.is_mlx()
4240 && !m.tags.iter().any(|t| t == "requires-mlx-vlm")
4245 && !m.tags.contains(&"speech".to_string())
4246 })
4247 .collect();
4248 assert!(
4249 !mlx_models.is_empty(),
4250 "catalog should contain at least one plain MLX model — \
4251 otherwise this F1 regression guard has nothing to guard"
4252 );
4253
4254 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4255 {
4256 let any_available = mlx_models.iter().any(|m| m.available);
4260 assert!(
4261 any_available,
4262 "on macOS arm64 with MLX enabled, at least one plain MLX \
4263 model with hf_repo should be available — none were"
4264 );
4265 }
4266 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4267 {
4268 for m in &mlx_models {
4271 assert!(
4272 !m.available,
4273 "MLX model {} is marked available on a non-MLX target — \
4274 the adaptive router will add it to fallback chains \
4275 and dispatch will fail (Parslee-ai/car#231 §7.1)",
4276 m.id
4277 );
4278 }
4279 }
4280 }
4281
4282 #[test]
4285 fn builtin_catalog_json_parses() {
4286 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON)
4287 .expect("builtin_catalog.json must be valid ModelSchema array");
4288 assert!(
4289 !catalog.is_empty(),
4290 "embedded catalog has no entries — that's almost certainly wrong"
4291 );
4292
4293 let mut seen = std::collections::HashSet::new();
4294 for entry in &catalog {
4295 assert!(
4296 seen.insert(entry.id.clone()),
4297 "duplicate id in builtin_catalog.json: {}",
4298 entry.id
4299 );
4300 }
4301 }
4302
4303 #[test]
4304 fn codex_subscription_row_has_pinnable_text_only_identity() {
4305 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
4306 let row = catalog
4307 .iter()
4308 .find(|model| model.id == "openai/gpt-5.6-sol:high")
4309 .expect("catalog publishes the Codex subscription identity");
4310 assert_eq!(row.name, "gpt-5.6-sol:high");
4311 assert!(matches!(
4312 &row.source,
4313 ModelSource::CodexCli { model } if model == "gpt-5.6-sol:high"
4314 ));
4315 assert!(row.has_capability(ModelCapability::Generate));
4316 assert!(row.has_capability(ModelCapability::Reasoning));
4317 assert!(!row.has_capability(ModelCapability::ToolUse));
4318 assert!(!row.has_capability(ModelCapability::MultiToolCall));
4319 assert!(!row.has_capability(ModelCapability::Vision));
4320 assert_eq!(row.supported_params, vec![GenerateParam::MaxTokens]);
4321 assert!(!row.downloads_weights());
4322 }
4323
4324 #[test]
4331 fn in_process_qwen3_models_declare_tool_use() {
4332 use crate::schema::ModelSource;
4333 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
4334 let tool_sizes = ["qwen3-4b", "qwen3-8b", "qwen3-30b-a3b"];
4337 let mut checked = 0;
4338 for entry in &catalog {
4339 let in_process = matches!(
4340 entry.source,
4341 ModelSource::Mlx { .. } | ModelSource::Local { .. }
4342 );
4343 if !in_process || !tool_sizes.iter().any(|s| entry.id.contains(s)) {
4344 continue;
4345 }
4346 assert!(
4347 entry.capabilities.contains(&ModelCapability::ToolUse),
4348 "in-process Qwen3 model {} should advertise ToolUse — the local \
4349 generate path renders/parses tool calls",
4350 entry.id
4351 );
4352 checked += 1;
4353 }
4354 assert_eq!(
4355 checked, 6,
4356 "expected 6 in-process tool-capable Qwen3 entries (3 mlx + 3 gguf)"
4357 );
4358 }
4359
4360 #[test]
4361 fn public_benchmarks_round_trip_through_model_info() {
4362 use crate::schema::BenchmarkScore;
4363 let mut reg = test_registry();
4364 let mut schema = reg
4365 .find_by_name("Qwen3-4B")
4366 .expect("catalog has Qwen3-4B")
4367 .clone();
4368 schema.id = "test/qwen3-4b-with-bench".into();
4369 schema.public_benchmarks = vec![
4370 BenchmarkScore {
4371 name: "MMLU-Pro".into(),
4372 score: 0.482,
4373 harness: Some("5-shot CoT".into()),
4374 source_url: Some("https://example.invalid/qwen3-4b-card".into()),
4375 measured_at: Some("2025-08-12".into()),
4376 },
4377 BenchmarkScore {
4378 name: "HumanEval".into(),
4379 score: 0.713,
4380 harness: Some("pass@1".into()),
4381 source_url: None,
4382 measured_at: None,
4383 },
4384 ];
4385 reg.register(schema);
4386
4387 let stored = reg
4388 .get("test/qwen3-4b-with-bench")
4389 .expect("registered model is retrievable");
4390 let info = ModelInfo::from(stored);
4391 assert_eq!(info.public_benchmarks.len(), 2);
4392
4393 let json = serde_json::to_string(&info).unwrap();
4395 assert!(json.contains("\"public_benchmarks\""));
4396 assert!(json.contains("\"MMLU-Pro\""));
4397 assert!(json.contains("\"5-shot CoT\""));
4398
4399 let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
4401 assert_eq!(decoded.public_benchmarks.len(), 2);
4402 assert_eq!(decoded.public_benchmarks[0].name, "MMLU-Pro");
4403 assert_eq!(decoded.public_benchmarks[1].name, "HumanEval");
4404 }
4405
4406 #[test]
4407 fn public_benchmarks_default_to_empty_when_absent_in_json() {
4408 let legacy_json = r#"{
4411 "id": "legacy/test:1",
4412 "name": "Legacy Test",
4413 "provider": "test",
4414 "family": "test",
4415 "version": "",
4416 "capabilities": ["generate"],
4417 "context_length": 4096,
4418 "param_count": "1B",
4419 "quantization": null,
4420 "performance": {},
4421 "cost": {},
4422 "source": { "type": "ollama", "model_tag": "legacy:1" },
4423 "tags": [],
4424 "supported_params": []
4425 }"#;
4426 let schema: ModelSchema = serde_json::from_str(legacy_json).unwrap();
4427 assert!(schema.public_benchmarks.is_empty());
4428 }
4429
4430 #[test]
4431 fn find_by_name() {
4432 let reg = test_registry();
4433 let m = reg.find_by_name("Qwen3-4B").unwrap();
4434 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4435 assert_eq!(m.id, "mlx/qwen3-4b:4bit");
4436 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4437 assert_eq!(m.id, "qwen/qwen3-4b:q4_k_m");
4438 assert!(m.has_capability(ModelCapability::Code));
4439 }
4440
4441 #[test]
4442 fn query_by_capability() {
4443 let reg = test_registry();
4444 let embed_models = reg.query_by_capability(ModelCapability::Embed);
4445 assert_eq!(embed_models.len(), 2);
4446 assert!(embed_models
4447 .iter()
4448 .any(|model| model.name == "Qwen3-Embedding-0.6B"));
4449 assert!(embed_models
4450 .iter()
4451 .any(|model| model.name == "Qwen3-Embedding-0.6B-MLX"));
4452 }
4453
4454 #[test]
4455 fn query_with_filter() {
4456 let reg = test_registry();
4457 let code_small = reg.query(&ModelFilter {
4458 capabilities: vec![ModelCapability::Code],
4459 max_size_mb: Some(3000),
4460 local_only: true,
4461 ..Default::default()
4462 });
4463 assert_eq!(code_small.len(), 4);
4465 }
4466
4467 #[test]
4468 fn register_remote() {
4469 let mut reg = test_registry();
4470 let initial_len = reg.list().len();
4471 let initial_reasoning_len = reg
4472 .query(&ModelFilter {
4473 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
4474 ..Default::default()
4475 })
4476 .len();
4477 let remote = ModelSchema {
4478 id: "anthropic/claude-sonnet-4-6:latest".into(),
4479 name: "Claude Sonnet 4.6".into(),
4480 provider: "anthropic".into(),
4481 family: "claude-4".into(),
4482 version: "latest".into(),
4483 capabilities: vec![
4484 ModelCapability::Generate,
4485 ModelCapability::Code,
4486 ModelCapability::Reasoning,
4487 ModelCapability::ToolUse,
4488 ],
4489 context_length: 200000,
4490 max_output_tokens: None,
4491 param_count: String::new(),
4492 quantization: None,
4493 performance: PerformanceEnvelope {
4494 latency_p50_ms: Some(2000),
4495 ..Default::default()
4496 },
4497 cost: CostModel {
4498 input_per_mtok: Some(3.0),
4499 output_per_mtok: Some(15.0),
4500 ..Default::default()
4501 },
4502 source: ModelSource::RemoteApi {
4503 endpoint: "https://api.anthropic.com/v1/messages".into(),
4504 api_key_env: "ANTHROPIC_API_KEY".into(),
4505 api_key_envs: vec![],
4506 api_version: Some("2023-06-01".into()),
4507 protocol: ApiProtocol::Anthropic,
4508 },
4509 tags: vec![],
4510 supported_params: vec![],
4511 public_benchmarks: vec![],
4512 trust_tier: crate::schema::TrustTier::Curated,
4513 deprecated: false,
4514 available: false,
4515 weights_ready: false,
4516 };
4517
4518 reg.register(remote);
4519 assert_eq!(reg.list().len(), initial_len);
4521
4522 let reasoning = reg.query(&ModelFilter {
4523 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
4524 ..Default::default()
4525 });
4526 assert_eq!(reasoning.len(), initial_reasoning_len);
4528 }
4529
4530 #[test]
4531 fn unregister() {
4532 let mut reg = test_registry();
4533 let initial_len = reg.list().len();
4534 let removed = reg.unregister("qwen/qwen3-0.6b:q8_0");
4535 assert!(removed.is_some());
4536 assert_eq!(reg.list().len(), initial_len - 1);
4537 }
4538
4539 #[test]
4540 fn speech_models_are_curated() {
4541 let reg = test_registry();
4542 let stt = reg.query_by_capability(ModelCapability::SpeechToText);
4543 let tts = reg.query_by_capability(ModelCapability::TextToSpeech);
4544 assert_eq!(stt.len(), 3);
4546 assert_eq!(tts.len(), 5);
4548 let whisper = stt
4551 .iter()
4552 .find(|m| m.name == "Whisper-large-v3-turbo-q5_0")
4553 .expect("whisper STT model should be curated");
4554 assert!(whisper.is_local());
4555 assert!(matches!(
4556 whisper.source,
4557 crate::schema::ModelSource::WhisperCpp { .. }
4558 ));
4559 }
4560
4561 #[test]
4562 fn qwen_8b_variants_keep_tool_use_consistent() {
4563 let reg = test_registry();
4568 for name in ["Qwen3-8B", "Qwen3-8B-MLX"] {
4569 let model = reg.find_by_name(name).expect("model should exist");
4570 assert!(model.has_capability(ModelCapability::ToolUse));
4571 assert!(model.has_capability(ModelCapability::MultiToolCall));
4572 }
4573 }
4574
4575 #[test]
4576 fn mac_name_resolution_prefers_mlx_siblings() {
4577 #[allow(unused_variables)]
4580 let reg = test_registry();
4581 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4582 {
4583 assert_eq!(
4584 reg.find_by_name("Qwen3-0.6B").unwrap().id,
4585 "mlx/qwen3-0.6b:6bit"
4586 );
4587 assert_eq!(
4588 reg.find_by_name("Qwen3-1.7B").unwrap().id,
4589 "mlx/qwen3-1.7b:3bit"
4590 );
4591 assert_eq!(
4592 reg.find_by_name("Qwen3-Embedding-0.6B").unwrap().id,
4593 "mlx/qwen3-embedding-0.6b:mxfp8"
4594 );
4595 }
4596 }
4597
4598 #[test]
4599 fn remote_multimodal_models_are_curated_as_vision_capable() {
4600 let reg = test_registry();
4601 for name in [
4602 "claude-opus-4-7",
4603 "claude-opus-4-6",
4604 "claude-sonnet-4-6",
4605 "claude-haiku-4-5",
4606 "gpt-5.4",
4607 "gpt-5.4-mini",
4608 "o3",
4609 "o4-mini",
4610 "gpt-4.1-mini",
4611 "gemini-2.5-pro",
4612 "gemini-2.5-flash",
4613 ] {
4614 let model = reg.find_by_name(name).expect("model should exist");
4615 assert!(
4616 model.has_capability(ModelCapability::Vision),
4617 "{name} should be curated as vision-capable"
4618 );
4619 }
4620 }
4621
4622 #[test]
4623 fn qwen25vl_entries_are_replaced_by_qwen3vl_in_builtin_catalog() {
4624 let reg = test_registry();
4625
4626 let stale_ids = [
4627 "mlx/qwen2.5-vl-3b:4bit",
4629 "mlx/qwen2.5-vl-7b:4bit",
4630 "mlx-vlm/qwen2.5-vl-3b:4bit",
4633 "mlx-vlm/qwen2.5-vl-7b:4bit",
4634 "vllm-mlx/qwen2.5-vl-3b:4bit",
4636 ];
4637 for id in stale_ids {
4638 assert!(
4639 reg.get(id).is_none(),
4640 "{id} is superseded by Qwen3-VL; the catalog must not advertise it"
4641 );
4642 }
4643
4644 let vision_ids: Vec<&str> = reg
4645 .query_by_capability(ModelCapability::Vision)
4646 .into_iter()
4647 .map(|model| model.id.as_str())
4648 .collect();
4649 for stale in stale_ids {
4650 assert!(
4651 !vision_ids.contains(&stale),
4652 "{stale} must not be reachable through the Vision capability index"
4653 );
4654 }
4655 assert!(
4656 vision_ids.contains(&"mlx-vlm/qwen3-vl-2b:bf16"),
4657 "Qwen3-VL is the supported local VL family and must route as Vision"
4658 );
4659 }
4660
4661 #[test]
4662 fn gemini_models_are_curated_for_multimodal_tool_use() {
4663 let reg = test_registry();
4664 for name in ["gemini-2.5-pro", "gemini-2.5-flash"] {
4665 let model = reg.find_by_name(name).expect("model should exist");
4666 assert!(model.has_capability(ModelCapability::Vision));
4667 assert!(model.has_capability(ModelCapability::ToolUse));
4668 assert!(model.has_capability(ModelCapability::MultiToolCall));
4669 }
4670 }
4671
4672 #[test]
4673 fn model_info_publishes_declared_prices_and_keeps_unpriced_distinct_from_free() {
4674 let reg = test_registry();
4675
4676 let opus = reg
4678 .list()
4679 .into_iter()
4680 .find(|m| m.id == "openrouter/anthropic/claude-opus-4.8")
4681 .map(ModelInfo::from)
4682 .expect("curated opus-4.8 row is present on first boot");
4683 assert_eq!(opus.cost.input_per_mtok, Some(5.0));
4684 assert_eq!(opus.cost.output_per_mtok, Some(25.0));
4685 assert_eq!(opus.cost.cache_read_input_per_mtok, Some(0.5));
4686 assert_eq!(opus.cost.cache_write_input_per_mtok, Some(6.25));
4687
4688 let gpt = reg
4690 .list()
4691 .into_iter()
4692 .find(|m| m.id == "openrouter/openai/gpt-5.4")
4693 .map(ModelInfo::from)
4694 .expect("curated gpt-5.4 row");
4695 assert_eq!(gpt.cost.pricing_tiers.len(), 1);
4696 assert_eq!(gpt.cost.prices_for(272_000).input_per_mtok, Some(5.0));
4697
4698 let local = reg
4701 .list()
4702 .into_iter()
4703 .find(|m| m.is_local() && m.cost.input_per_mtok.is_none())
4704 .map(ModelInfo::from)
4705 .expect("the built-in catalog ships unpriced local models");
4706 let json = serde_json::to_value(&local).unwrap();
4707 assert!(json["cost"]["input_per_mtok"].is_null());
4708 assert!(json["cost"]["output_per_mtok"].is_null());
4709 assert_ne!(json["cost"]["input_per_mtok"], serde_json::json!(0.0));
4710 }
4711
4712 #[test]
4713 fn a_hand_registered_copy_of_a_curated_id_does_not_double_the_row() {
4714 let mut reg = test_registry();
4715 let id = "openrouter/anthropic/claude-opus-4.8";
4716 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
4717
4718 let mut copy = reg
4721 .list()
4722 .into_iter()
4723 .find(|m| m.id == id)
4724 .cloned()
4725 .expect("curated row");
4726 copy.name = "hand-registered".into();
4727 reg.register_user_model(copy);
4728 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
4729 }
4730
4731 #[test]
4739 fn managed_alias_publishes_prices_without_disclosing_the_upstream_id_in_the_catalog_view() {
4740 let reg = test_registry();
4741 let alias = reg
4742 .list()
4743 .into_iter()
4744 .find(|m| m.id == "parslee/openrouter/frontier-deep-next")
4745 .map(ModelInfo::from)
4746 .expect("managed alias for the new curated row");
4747
4748 assert_eq!(alias.cost.input_per_mtok, Some(5.0));
4749 assert_eq!(alias.cost.output_per_mtok, Some(25.0));
4750 assert_eq!(alias.cost.cache_read_input_per_mtok, Some(0.5));
4751 assert_eq!(alias.cost.cache_write_input_per_mtok, Some(6.25));
4752
4753 let wire = serde_json::to_string(&alias).unwrap();
4754 assert!(!wire.contains("claude-opus-4.8"));
4755 assert!(!wire.contains("anthropic/"));
4756 }
4757
4758 #[test]
4759 fn model_info_from_an_older_daemon_without_cost_still_parses() {
4760 let legacy = serde_json::json!({
4763 "id": "legacy/model",
4764 "name": "legacy",
4765 "provider": "legacy",
4766 "capabilities": ["generate"],
4767 "param_count": "",
4768 "size_mb": 0,
4769 "context_length": 8192,
4770 "available": true,
4771 "is_local": false
4772 });
4773 let info: ModelInfo = serde_json::from_value(legacy).expect("older catalog row parses");
4774 assert!(info.cost.input_per_mtok.is_none());
4775 assert!(info.cost.output_per_mtok.is_none());
4776 assert!(info.cost.pricing_tiers.is_empty());
4777 assert!(info.max_output_tokens.is_none());
4778 assert!(info.car_enabled, "legacy rows default to enabled");
4779 assert!(!info.can_remove);
4780 assert!(!info.in_use);
4781 assert!(info.management_evidence.is_none());
4782 }
4783
4784 #[test]
4785 fn visual_generation_models_are_curated() {
4786 let reg = test_registry();
4787 assert_eq!(
4788 reg.query_by_capability(ModelCapability::ImageGeneration)
4789 .len(),
4790 1
4791 );
4792 assert_eq!(
4793 reg.query_by_capability(ModelCapability::VideoGeneration)
4794 .len(),
4795 1
4796 );
4797 }
4798}
4799
4800#[cfg(test)]
4809mod builtin_catalog_validation {
4810 use super::*;
4811 use crate::schema::ModelSource;
4812
4813 fn weight_repo(source: &ModelSource) -> Option<&str> {
4815 match source {
4816 ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
4817 ModelSource::Local { hf_repo, .. } => Some(hf_repo),
4818 ModelSource::ManagedVllmMlx { hf_repo, .. } => Some(hf_repo),
4819 _ => None,
4820 }
4821 }
4822
4823 #[test]
4824 fn ids_are_unique() {
4825 let catalog = builtin_catalog();
4826 let mut seen: Vec<&str> = Vec::new();
4827 for model in &catalog {
4828 assert!(
4829 !seen.contains(&model.id.as_str()),
4830 "duplicate catalog id `{}` — the later entry silently shadows the earlier",
4831 model.id
4832 );
4833 seen.push(&model.id);
4834 }
4835 }
4836
4837 #[test]
4838 fn exact_frontier_rows_lock_native_selectors_and_digests() {
4839 let catalog = builtin_catalog();
4840 for (id, name, version, expected_digest) in [
4841 (
4842 "openai/gpt-5.5-2026-04-23",
4843 "gpt-5.5-2026-04-23",
4844 "2026-04-23",
4845 "aa1b0741114e6d9d0e1a758a3ab76005fe55dbcfe050d6a10a5dc37675b07b8f",
4846 ),
4847 (
4848 "anthropic/claude-opus-4-8",
4849 "claude-opus-4-8",
4850 "4.8",
4851 "10504959e51dc76c3563df91ae2eaba57cf814834ecd657232c50f264f9e735e",
4852 ),
4853 (
4854 "openai/gpt-5.6-sol:high",
4855 "gpt-5.6-sol:high",
4856 "latest",
4857 "6cc4a9a80708dbb99c8e4edb6c13be46b2b727a798cb354dcc5598d9004c4acd",
4858 ),
4859 ] {
4860 let row = catalog
4861 .iter()
4862 .find(|model| model.id == id)
4863 .unwrap_or_else(|| panic!("missing exact production row {id}"));
4864 assert_eq!(row.name, name);
4865 assert_eq!(row.version, version);
4866 assert_eq!(
4867 crate::catalog_identity::row_digest(row).unwrap(),
4868 expected_digest,
4869 "CAR row digest drifted for {id}"
4870 );
4871 }
4872 }
4873
4874 #[test]
4875 fn weight_repos_are_well_formed_huggingface_ids() {
4876 for model in builtin_catalog() {
4877 let Some(repo) = weight_repo(&model.source) else {
4878 continue;
4879 };
4880 assert_eq!(
4881 repo.split('/').count(),
4882 2,
4883 "{}: `{repo}` is not an `org/name` HuggingFace id",
4884 model.id
4885 );
4886 assert!(
4887 !repo.split('/').any(str::is_empty),
4888 "{}: `{repo}` has an empty path segment",
4889 model.id
4890 );
4891 assert!(
4892 !repo.contains(char::is_whitespace),
4893 "{}: `{repo}` contains whitespace",
4894 model.id
4895 );
4896 }
4897 }
4898
4899 #[test]
4903 fn param_counts_are_parseable_or_deliberately_empty() {
4904 for model in builtin_catalog() {
4905 if weight_repo(&model.source).is_none() || model.param_count.is_empty() {
4906 continue;
4907 }
4908 assert!(
4909 model.param_count.starts_with(|c: char| c.is_ascii_digit()),
4910 "{}: param_count `{}` does not start with a number, so the quality \
4911 prior cannot read it — leave it empty rather than descriptive",
4912 model.id,
4913 model.param_count
4914 );
4915 }
4916 }
4917
4918 #[test]
4921 fn catalog_vllm_mlx_entries_use_explicit_managed_ownership() {
4922 for model in builtin_catalog() {
4923 if !model.is_vllm_mlx() {
4924 continue;
4925 }
4926 assert!(
4927 model.is_car_managed_vllm_mlx(),
4928 "{}: a CAR-supervised catalog row must opt into ManagedVllmMlx; \
4929 loopback alone cannot confer ownership",
4930 model.id
4931 );
4932 }
4933 }
4934
4935 #[test]
4936 fn generate_capable_models_declare_a_context_window() {
4937 for model in builtin_catalog() {
4938 if !model.has_capability(crate::schema::ModelCapability::Generate) {
4939 continue;
4940 }
4941 assert!(
4942 model.context_length > 0,
4943 "{}: a generate-capable model with no context_length breaks budget sizing",
4944 model.id
4945 );
4946 }
4947 }
4948
4949 #[test]
4950 fn every_entry_declares_at_least_one_capability() {
4951 for model in builtin_catalog() {
4952 assert!(
4953 !model.capabilities.is_empty(),
4954 "{}: an entry with no capabilities can never be routed to",
4955 model.id
4956 );
4957 }
4958 }
4959}
4960
4961#[cfg(test)]
4962mod gguf_quantization_tests {
4963 use crate::schema::{QuantScheme, Quantization};
4964
4965 fn quantization_from_gguf_filename(name: &str) -> Option<Quantization> {
4966 Quantization::from_gguf_filename(name)
4967 }
4968
4969 #[test]
4970 fn reads_the_quantization_a_gguf_file_names() {
4971 let cases = [
4972 ("Qwen3-8B-Q4_K_M.gguf", "Q4_K_M", QuantScheme::KQuantMixed),
4973 (
4974 "Qwen3-Embedding-0.6B-Q8_0.gguf",
4975 "Q8_0",
4976 QuantScheme::RtnBlock,
4977 ),
4978 (
4979 "ggml-large-v3-turbo-q5_0.gguf",
4980 "q5_0",
4981 QuantScheme::RtnBlock,
4982 ),
4983 ("model-IQ4_XS.gguf", "IQ4_XS", QuantScheme::KQuantMixed),
4984 ];
4985 for (filename, label, scheme) in cases {
4986 let q = quantization_from_gguf_filename(filename)
4987 .unwrap_or_else(|| panic!("no quantization found in {filename}"));
4988 assert_eq!(q.label, label, "label for {filename}");
4989 assert_eq!(q.scheme, scheme, "scheme for {filename}");
4990 }
4991 }
4992
4993 #[test]
4995 fn returns_none_when_the_name_says_nothing() {
4996 for filename in ["model.gguf", "llama-2-7b-chat.gguf", "ggml-base.gguf"] {
4997 assert!(
4998 quantization_from_gguf_filename(filename).is_none(),
4999 "should not have guessed from {filename}"
5000 );
5001 }
5002 }
5003
5004 #[test]
5007 fn the_rightmost_match_wins() {
5008 let q = quantization_from_gguf_filename("q8-experiment-Q4_K_M.gguf").unwrap();
5009 assert_eq!(q.label, "Q4_K_M");
5010 }
5011}
5012
5013#[cfg(test)]
5014mod local_availability_tests {
5015 use super::*;
5016 use crate::schema::ModelSchema;
5017 use tempfile::TempDir;
5018
5019 fn gguf_row(id: &str, hf_repo: &str) -> ModelSchema {
5020 let mut schema: ModelSchema = serde_json::from_value(serde_json::json!({
5021 "id": id,
5022 "name": id.replace('/', "-"),
5023 "provider": "qwen",
5024 "family": "qwen3",
5025 "capabilities": ["generate"],
5026 "context_length": 32768,
5027 "param_count": "8B",
5028 "source": {
5029 "type": "local",
5030 "hf_repo": hf_repo,
5031 "hf_filename": "model.gguf",
5032 "tokenizer_repo": hf_repo,
5033 },
5034 "cost": { "size_mb": 4900 },
5035 }))
5036 .unwrap();
5037 schema.available = false;
5038 schema
5039 }
5040
5041 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
5052 #[test]
5053 fn a_declared_repo_is_available_before_it_is_downloaded() {
5054 let _environment = crate::openrouter::test_environment_scope();
5055 let tmp = TempDir::new().unwrap();
5056 let models = tmp.path().join("models");
5057 std::fs::create_dir_all(&models).unwrap();
5058
5059 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
5060 reg.register(gguf_row("qwen/test-8b:q4_k_m", "Qwen/Qwen3-8B-GGUF"));
5061
5062 let model = reg.get("qwen/test-8b:q4_k_m").expect("registered");
5063 assert!(
5064 model.available,
5065 "a GGUF model with a repo to fetch from must not report unavailable \
5066 just because nothing has downloaded it yet"
5067 );
5068 }
5069
5070 #[test]
5074 fn a_row_with_nowhere_to_fetch_from_stays_unavailable() {
5075 let _environment = crate::openrouter::test_environment_scope();
5076 let tmp = TempDir::new().unwrap();
5077 let models = tmp.path().join("models");
5078 std::fs::create_dir_all(&models).unwrap();
5079
5080 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
5081 reg.register(gguf_row("local/scanned", ""));
5082
5083 let model = reg.get("local/scanned").expect("registered");
5084 assert!(
5085 !model.available,
5086 "an empty hf_repo has no download to promise"
5087 );
5088 }
5089}