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 on_disk_discovered_ids: HashSet<String>,
174 user_config_path: PathBuf,
177 ambient_progress: ProgressSink,
191 session: SessionProbe,
196}
197
198#[derive(Debug, Clone, Deserialize)]
199struct ModelUpgradeRule {
200 from_ids: Vec<String>,
201 to_id: String,
202 reason: String,
203 target_runtime: Option<String>,
204 target_runtime_requirement: Option<String>,
205 #[serde(default)]
206 minimum_runtimes: Vec<ModelRuntimeRequirement>,
207 #[serde(default = "default_remove_old_after_available")]
208 remove_old_after_available: bool,
209}
210
211fn default_remove_old_after_available() -> bool {
212 true
213}
214
215fn environment_credential_available(env_var: &str) -> bool {
216 std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
217}
218
219fn passive_parslee_oauth_available() -> bool {
220 matches!(
221 car_auth::credential_authority_hint().state,
222 car_auth::CredentialAuthorityState::Configured
223 )
224}
225
226fn proprietary_auth_available(
232 model_id: &str,
233 schema_provider: &str,
234 source_provider: &str,
235 auth: &ProprietaryAuth,
236 parslee_oauth_available: bool,
237 resolved: &std::collections::HashMap<String, bool>,
238) -> bool {
239 if crate::openrouter::is_curated_managed_gateway_alias(model_id)
255 && crate::openrouter::gateway_unconfigured()
256 {
257 return false;
258 }
259 if crate::parslee_credential::credential_rejected()
273 && matches!(auth, ProprietaryAuth::OAuth2Pkce { .. })
274 {
275 return false;
276 }
277 match auth {
278 ProprietaryAuth::ApiKeyEnv { env_var } | ProprietaryAuth::BearerTokenEnv { env_var } => {
279 resolved.get(env_var).copied().unwrap_or(false)
280 }
281 ProprietaryAuth::OAuth2Pkce { .. } => {
282 schema_provider.eq_ignore_ascii_case("parslee")
283 && source_provider.eq_ignore_ascii_case("parslee")
284 && parslee_oauth_available
285 }
286 }
287}
288
289fn model_upgrade_rules() -> Vec<ModelUpgradeRule> {
290 serde_json::from_str(include_str!("../assets/model-upgrades.json"))
291 .expect("built-in model-upgrades.json should parse")
292}
293
294pub const USER_MODELS_FILE: &str = "models.json";
296
297pub fn user_config_path() -> Option<PathBuf> {
315 car_home::root().map(|root| root.join(USER_MODELS_FILE))
316}
317
318impl UnifiedRegistry {
319 pub fn new(models_dir: PathBuf) -> Self {
323 Self::new_with_state_root(car_home::root_or_relative(), models_dir)
324 }
325
326 pub fn new_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
330 let catalog_public_key = std::env::var("CAR_CATALOG_PUBKEY").ok();
331 Self::new_with_catalog_public_key(state_root, models_dir, catalog_public_key.as_deref())
332 }
333
334 fn new_with_catalog_public_key(
335 state_root: PathBuf,
336 models_dir: PathBuf,
337 catalog_public_key: Option<&str>,
338 ) -> Self {
339 #[cfg(not(test))]
347 let session = SessionProbe::Live;
348 #[cfg(test)]
349 let session = SessionProbe::Inert;
350 Self::new_with_session(state_root, models_dir, catalog_public_key, session)
351 }
352
353 pub(crate) fn new_with_session(
356 state_root: PathBuf,
357 models_dir: PathBuf,
358 catalog_public_key: Option<&str>,
359 session: SessionProbe,
360 ) -> Self {
361 let user_config_path = state_root.join(USER_MODELS_FILE);
362
363 let mut registry = Self {
364 models_dir,
365 state_root,
366 models: HashMap::new(),
367 project_model_ids: HashSet::new(),
368 builtin_model_ids: HashSet::new(),
369 user_config_ids: HashSet::new(),
370 on_disk_discovered_ids: HashSet::new(),
371 user_config_path,
372 ambient_progress: ProgressSink::none(),
373 session,
374 };
375 registry.load_builtin_catalog();
376 for schema in crate::catalog::load_cache(
381 &crate::catalog::cache_path(®istry.state_root),
382 catalog_public_key,
383 ) {
384 registry.register_signed_catalog_model(schema);
385 }
386 for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
391 ®istry.state_models_dir(),
392 )) {
393 if !registry.models.contains_key(&schema.id) {
394 registry.register(schema);
395 }
396 }
397 registry.refresh_availability();
398 let _ = registry.load_user_config();
400 registry.discover_on_disk_models();
404 registry
405 }
406
407 fn empty_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
408 let user_config_path = state_root.join(USER_MODELS_FILE);
409 Self {
410 models_dir,
411 state_root,
412 models: HashMap::new(),
413 project_model_ids: HashSet::new(),
414 builtin_model_ids: HashSet::new(),
415 user_config_ids: HashSet::new(),
416 on_disk_discovered_ids: HashSet::new(),
417 user_config_path,
418 ambient_progress: ProgressSink::none(),
419 session: SessionProbe::Inert,
422 }
423 }
424
425 #[cfg(test)]
440 pub fn new_empty(models_dir: PathBuf) -> Self {
441 let state_root = models_dir.parent().unwrap_or(&models_dir).to_path_buf();
442 Self::empty_with_state_root(state_root, models_dir)
443 }
444
445 pub(crate) fn new_isolated_for_diagnosis(state_root: PathBuf, models_dir: PathBuf) -> Self {
452 let mut registry = Self::empty_with_state_root(state_root, models_dir);
453 registry.discover_on_disk_models();
454 registry
455 }
456
457 fn state_models_dir(&self) -> PathBuf {
462 self.state_root.join("models")
463 }
464
465 fn discover_on_disk_models(&mut self) {
479 let entries = match std::fs::read_dir(&self.models_dir) {
480 Ok(e) => e,
481 Err(_) => return,
482 };
483 let known: std::collections::HashSet<String> = self
486 .models
487 .values()
488 .map(|m| m.name.to_ascii_lowercase())
489 .collect();
490
491 for entry in entries.flatten() {
492 let path = entry.path();
493 if !path.is_dir() {
494 continue;
495 }
496 let Some(name) = path
497 .file_name()
498 .and_then(|n| n.to_str())
499 .map(str::to_string)
500 else {
501 continue;
502 };
503 if known.contains(&name.to_ascii_lowercase()) {
504 continue;
505 }
506
507 let Some(schema) = synthesize_local_schema(&name, &path) else {
508 continue;
509 };
510 tracing::info!(
511 id = %schema.id,
512 name = %name,
513 "auto-discovered uncatalogued local model under models_dir (car-releases#62)"
514 );
515 let id = schema.id.clone();
516 self.register(schema);
517 if self.models.contains_key(&id) {
518 self.on_disk_discovered_ids.insert(id);
519 }
520 }
521 }
522
523 pub(crate) fn prune_missing_on_disk_models(&mut self) {
530 let missing = self
531 .on_disk_discovered_ids
532 .iter()
533 .filter(|id| {
534 self.models
535 .get(id.as_str())
536 .is_none_or(|schema| !self.models_dir.join(&schema.name).is_dir())
537 })
538 .cloned()
539 .collect::<Vec<_>>();
540 for id in missing {
541 tracing::debug!(
542 model_id = %id,
543 "dropping vanished auto-discovered model; coordination records do not register models"
544 );
545 self.on_disk_discovered_ids.remove(&id);
546 self.models.remove(&id);
547 }
548 }
549
550 pub fn register(&mut self, mut schema: ModelSchema) {
558 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
559 warn!(id = %schema.id, "ignoring user registration for reserved Parslee-managed alias");
560 return;
561 }
562 if self.project_model_ids.contains(&schema.id) {
563 warn!(id = %schema.id, "ignoring public registration for project-owned exact id");
564 return;
565 }
566 schema.mark_user_registered();
567 let id = schema.id.clone();
568 if self.register_preserving_trust(schema) {
569 self.on_disk_discovered_ids.remove(&id);
570 }
571 }
572
573 pub(crate) fn register_project_model(&mut self, schema: ModelSchema) -> bool {
579 let id = schema.id.clone();
580 if !self.register_preserving_trust(schema) {
581 return false;
582 }
583 self.on_disk_discovered_ids.remove(&id);
584 self.project_model_ids.insert(id);
585 true
586 }
587
588 fn register_signed_catalog_model(&mut self, schema: ModelSchema) {
592 if self.builtin_model_ids.contains(&schema.id) {
593 warn!(id = %schema.id, "ignoring signed catalog row for compiled builtin exact id");
594 return;
595 }
596 if self.project_model_ids.contains(&schema.id) {
597 warn!(id = %schema.id, "ignoring duplicate signed catalog row for project-owned exact id");
598 return;
599 }
600 self.register_project_model(schema);
601 }
602
603 fn register_preserving_trust(&mut self, mut schema: ModelSchema) -> bool {
604 if let Err(error) = crate::catalog_identity::row_digest(&schema) {
605 warn!(id = %schema.id, %error, "rejecting model without canonical catalog identity");
606 return false;
607 }
608 if schema.is_mlx() || schema.is_car_managed_vllm_mlx() {
610 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
621 {
622 schema.available = if schema.tags.contains(&"speech".to_string()) {
623 speech_mlx_available()
624 } else if let ModelSource::Mlx { ref hf_repo, .. }
625 | ModelSource::ManagedVllmMlx { ref hf_repo, .. } = schema.source
626 {
627 let mlx_dir = self.models_dir.join(&schema.name);
633 mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty()
634 } else {
635 let mlx_dir = self.models_dir.join(&schema.name);
636 mlx_dir_has_weights(&mlx_dir)
637 };
638 }
639 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
640 {
641 schema.available = false;
642 }
643 } else if schema.is_vllm_mlx() {
644 schema.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available;
646 } else if matches!(schema.source, ModelSource::WhisperCpp { .. }) {
647 schema.available = true;
652 } else if matches!(schema.source, ModelSource::WindowsSpeech {}) {
653 schema.available = cfg!(target_os = "windows");
655 } else if schema.is_codex_cli() {
656 schema.available = crate::backend::codex_cli::is_available();
660 } else if schema.is_local() {
661 let local_path = self.models_dir.join(&schema.name).join("model.gguf");
662 let lazily_fetchable = matches!(
668 schema.source,
669 ModelSource::Local { ref hf_repo, .. } if !hf_repo.is_empty()
670 ) && !cfg!(all(
671 target_os = "macos",
672 target_arch = "aarch64",
673 not(car_skip_mlx)
674 ));
675 schema.available = local_path.exists() || lazily_fetchable;
676 } else if schema.is_remote() {
677 schema.available = match schema.source {
681 ModelSource::RemoteApi {
682 protocol: crate::schema::ApiProtocol::OpenRouter,
683 ..
684 } => crate::openrouter::credential_source().is_some(),
685 ModelSource::RemoteApi {
686 ref api_key_env, ..
687 } => environment_credential_available(api_key_env),
688 ModelSource::Proprietary {
689 ref provider,
690 ref auth,
691 ..
692 } => {
693 let resolved = match auth {
694 ProprietaryAuth::ApiKeyEnv { env_var }
695 | ProprietaryAuth::BearerTokenEnv { env_var } => {
696 std::collections::HashMap::from([(
697 env_var.clone(),
698 environment_credential_available(env_var),
699 )])
700 }
701 ProprietaryAuth::OAuth2Pkce { .. } => Default::default(),
702 };
703 proprietary_auth_available(
704 &schema.id,
705 &schema.provider,
706 provider,
707 auth,
708 self.session.available(),
709 &resolved,
710 )
711 }
712 _ => schema.available,
713 };
714 }
715 schema.weights_ready = physical_weights_ready(&schema, &self.models_dir);
728 info!(
729 id = %schema.id,
730 name = %schema.name,
731 available = schema.available,
732 weights_ready = schema.weights_ready,
733 "registered model"
734 );
735 self.models.insert(schema.id.clone(), schema);
736 true
737 }
738
739 pub fn register_user_model(&mut self, mut schema: ModelSchema) {
746 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
747 warn!(id = %schema.id, "ignoring persisted user model for reserved Parslee-managed alias");
748 return;
749 }
750 if self.project_model_ids.contains(&schema.id) {
751 warn!(id = %schema.id, "ignoring persisted user model for project-owned exact id");
752 return;
753 }
754 schema.mark_user_registered();
755 let id = schema.id.clone();
756 if self.register_preserving_trust(schema) {
757 self.on_disk_discovered_ids.remove(&id);
758 self.user_config_ids.insert(id);
759 }
760 }
761
762 pub fn unregister(&mut self, id: &str) -> Option<ModelSchema> {
764 self.on_disk_discovered_ids.remove(id);
765 let removed = self.models.remove(id);
766 if let Some(ref m) = removed {
767 info!(id = %m.id, "unregistered model");
768 }
769 removed
770 }
771
772 pub fn unregister_user_model(&mut self, id: &str) -> Option<ModelSchema> {
777 if !self.user_config_ids.remove(id) {
778 return None;
779 }
780 self.unregister(id)
781 }
782
783 pub fn list(&self) -> Vec<&ModelSchema> {
785 let mut models: Vec<&ModelSchema> = self.models.values().collect();
786 models.sort_by(|a, b| a.id.cmp(&b.id));
787 models
788 }
789
790 pub fn query(&self, filter: &ModelFilter) -> Vec<&ModelSchema> {
792 self.models
793 .values()
794 .filter(|m| {
795 if !filter.capabilities.iter().all(|c| m.has_capability(*c)) {
797 return false;
798 }
799 if let Some(max) = filter.max_size_mb {
801 if m.size_mb() > max && m.is_local() {
802 return false;
803 }
804 }
805 if let Some(max) = filter.max_latency_ms {
807 if let Some(p50) = m.performance.latency_p50_ms {
808 if p50 > max {
809 return false;
810 }
811 }
812 }
813 if let Some(max) = filter.max_cost_per_mtok {
815 if let Some(cost) = m.cost.output_per_mtok {
816 if cost > max {
817 return false;
818 }
819 }
820 }
821 if !filter.tags.iter().all(|t| m.tags.contains(t)) {
823 return false;
824 }
825 if let Some(ref p) = filter.provider {
827 if &m.provider != p {
828 return false;
829 }
830 }
831 if filter.local_only && !m.is_local() {
833 return false;
834 }
835 if filter.available_only && !m.available_now() {
837 return false;
838 }
839 true
840 })
841 .collect()
842 }
843
844 pub fn query_by_capability(&self, cap: ModelCapability) -> Vec<&ModelSchema> {
846 self.query(&ModelFilter {
847 capabilities: vec![cap],
848 ..Default::default()
849 })
850 }
851
852 pub fn available_upgrades(&self) -> Vec<ModelUpgrade> {
854 let mut upgrades = Vec::new();
855 for rule in model_upgrade_rules() {
856 let Some(from) = rule
857 .from_ids
858 .iter()
859 .find_map(|id| self.models.get(id.as_str()))
860 .filter(|schema| schema.available)
861 else {
862 continue;
863 };
864 let Some(to) = self.models.get(rule.to_id.as_str()) else {
865 continue;
866 };
867 upgrades.push(ModelUpgrade {
868 from_id: from.id.clone(),
869 from_name: from.name.clone(),
870 to_id: to.id.clone(),
871 to_name: to.name.clone(),
872 reason: rule.reason.clone(),
873 target_runtime: rule.target_runtime.clone(),
874 target_runtime_requirement: rule.target_runtime_requirement.clone(),
875 minimum_runtimes: rule.minimum_runtimes.clone(),
876 target_available: to.available,
877 target_pullable: matches!(
878 to.source,
879 ModelSource::Local { .. } | ModelSource::Mlx { .. }
880 ),
881 remove_old_supported: matches!(
882 from.source,
883 ModelSource::Local { .. } | ModelSource::Mlx { .. }
884 ) && rule.remove_old_after_available,
885 });
886 }
887 upgrades.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
888 upgrades.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
889 upgrades
890 }
891
892 pub fn get(&self, id: &str) -> Option<&ModelSchema> {
894 self.models.get(id)
895 }
896
897 pub fn registered_schema(&self, id: &str) -> Option<&ModelSchema> {
903 self.get(id)
904 }
905
906 pub fn all(&self) -> impl Iterator<Item = &ModelSchema> {
908 self.models.values()
909 }
910
911 pub fn find_by_name(&self, name: &str) -> Option<&ModelSchema> {
914 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
915 if !name.to_ascii_lowercase().ends_with("-mlx") {
916 if let Some(mlx_variant) = self
917 .models
918 .values()
919 .find(|m| m.name.eq_ignore_ascii_case(&format!("{name}-MLX")))
920 {
921 return Some(mlx_variant);
922 }
923 }
924
925 self.models
926 .values()
927 .find(|m| m.name.eq_ignore_ascii_case(name))
928 }
929
930 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
934 pub fn resolve_mlx_equivalent(&self, schema: &ModelSchema) -> Option<&ModelSchema> {
935 if schema.is_mlx() || schema.is_vllm_mlx() {
937 return None;
938 }
939 if !matches!(schema.source, ModelSource::Local { .. }) {
941 return None;
942 }
943 let primary_cap = schema.capabilities.first()?;
950 self.models.values().find(|m| {
951 m.is_mlx()
952 && m.family == schema.family
953 && m.param_count == schema.param_count
954 && m.capabilities.contains(primary_cap)
955 })
956 }
957
958 pub async fn ensure_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
960 let sink = self.ambient_progress.clone();
965 self.ensure_local_with_progress(id, &sink).await
966 }
967
968 pub fn set_ambient_progress(&mut self, sink: ProgressSink) {
975 self.ambient_progress = sink;
976 }
977
978 pub async fn ensure_local_with_progress(
985 &self,
986 id: &str,
987 sink: &ProgressSink,
988 ) -> Result<PathBuf, InferenceError> {
989 self.acquire_and_ensure(id, sink, false, None).await
990 }
991
992 pub(crate) async fn ensure_local_with_progress_staged(
997 &self,
998 id: &str,
999 sink: &ProgressSink,
1000 staging_dir: &Path,
1001 ) -> Result<PathBuf, InferenceError> {
1002 self.acquire_and_ensure(id, sink, false, Some(staging_dir))
1003 .await
1004 }
1005
1006 pub async fn redownload_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1012 self.acquire_and_ensure(id, &ProgressSink::none(), true, None)
1013 .await
1014 }
1015
1016 async fn acquire_and_ensure(
1017 &self,
1018 id: &str,
1019 sink: &ProgressSink,
1020 force: bool,
1021 managed_dir_override: Option<&Path>,
1022 ) -> Result<PathBuf, InferenceError> {
1023 let schema = self
1024 .get(id)
1025 .or_else(|| self.find_by_name(id))
1026 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1027 let model_name = schema.name.clone();
1028 let model_id = schema.id.clone();
1029 let needed_mb = schema.size_mb();
1030 let model_dir = self.models_dir.join(&schema.name);
1031
1032 let _guard = crate::download::acquire_model_lock(&model_id).await;
1034
1035 if !force && managed_dir_override.is_none() {
1039 if let Some(path) = self.try_reuse_local(schema).await? {
1040 return Ok(path);
1041 }
1042 }
1043
1044 if let Err(e) = crate::download::check_disk_space(&model_dir, needed_mb) {
1046 sink.emit(DownloadEvent::Failed { error: e.clone() });
1047 return Err(InferenceError::DownloadFailed(e));
1048 }
1049
1050 sink.emit(DownloadEvent::Started {
1051 model: model_name.clone(),
1052 total_files: 0,
1053 total_mb: needed_mb,
1054 });
1055 let result = self
1056 .ensure_local_inner(id, sink, force, managed_dir_override)
1057 .await;
1058 match &result {
1059 Ok(_) => sink.emit(DownloadEvent::Completed { model: model_name }),
1060 Err(e) => sink.emit(DownloadEvent::Failed {
1061 error: e.to_string(),
1062 }),
1063 }
1064 result
1065 }
1066
1067 async fn try_reuse_local(
1068 &self,
1069 schema: &ModelSchema,
1070 ) -> Result<Option<PathBuf>, InferenceError> {
1071 match &schema.source {
1072 ModelSource::Local { .. } => {
1073 let model_dir = self.models_dir.join(&schema.name);
1074 let model_path = model_dir.join("model.gguf");
1075 let tokenizer_path = model_dir.join("tokenizer.json");
1076
1077 if crate::download::cache_file_usable(&model_path)
1078 && crate::download::cache_file_usable(&tokenizer_path)
1079 {
1080 return Ok(Some(model_dir));
1081 }
1082 }
1083 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1084 let model_dir = self.models_dir.join(&schema.name);
1085 let config_path = model_dir.join("config.json");
1086 let is_diffusers = schema.capabilities.iter().any(|c| {
1087 matches!(
1088 c,
1089 ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
1090 )
1091 });
1092
1093 if mlx_dir_has_weights(&model_dir) && (is_diffusers || config_path.exists()) {
1094 if auxiliary_mlx_files_missing(&schema.name, hf_repo, &model_dir) {
1095 return Ok(None);
1099 }
1100 info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1101 return Ok(Some(model_dir));
1102 }
1103
1104 if let Some(snapshot_dir) =
1105 latest_huggingface_repo_snapshot(hf_repo).filter(|d| mlx_dir_has_weights(d))
1106 {
1107 if !auxiliary_mlx_files_missing(&schema.name, hf_repo, &snapshot_dir) {
1108 info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1109 return Ok(Some(snapshot_dir));
1110 }
1111 }
1112 }
1113 _ => {}
1114 }
1115
1116 Ok(None)
1117 }
1118
1119 async fn ensure_local_inner(
1120 &self,
1121 id: &str,
1122 sink: &ProgressSink,
1123 force: bool,
1124 managed_dir_override: Option<&Path>,
1125 ) -> Result<PathBuf, InferenceError> {
1126 let schema = self
1127 .get(id)
1128 .or_else(|| self.find_by_name(id))
1129 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1130
1131 match &schema.source {
1132 ModelSource::Local {
1133 hf_repo,
1134 hf_filename,
1135 tokenizer_repo,
1136 } => {
1137 let model_dir = managed_dir_override
1138 .map(Path::to_path_buf)
1139 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1140 let model_path = model_dir.join("model.gguf");
1141 let tokenizer_path = model_dir.join("tokenizer.json");
1142
1143 if !force
1144 && crate::download::cache_file_usable(&model_path)
1145 && crate::download::cache_file_usable(&tokenizer_path)
1146 {
1147 return Ok(model_dir);
1148 }
1149
1150 if hf_repo.is_empty() {
1157 let missing = [
1158 ("model.gguf", &model_path),
1159 ("tokenizer.json", &tokenizer_path),
1160 ]
1161 .into_iter()
1162 .filter(|(_, path)| !crate::download::cache_file_usable(path))
1163 .map(|(name, _)| name)
1164 .collect::<Vec<_>>()
1165 .join(" and ");
1166 return Err(InferenceError::InferenceFailed(format!(
1167 "{}: discovered on disk at {} but not loadable — the GGUF \
1168 backend reads `model.gguf` and `tokenizer.json` from the \
1169 model directory, and this one is missing {missing}. Rename \
1170 the weight file to `model.gguf` and add the tokenizer, or \
1171 register the model against its HuggingFace repo so CAR can \
1172 fetch both.",
1173 schema.name,
1174 model_dir.display()
1175 )));
1176 }
1177
1178 std::fs::create_dir_all(&model_dir)?;
1179
1180 if !crate::download::cache_file_usable(&model_path) {
1181 info!(model = %schema.name, repo = %hf_repo, "downloading model weights");
1182 sink.emit(DownloadEvent::FileStarted {
1183 filename: "model weights".into(),
1184 index: 1,
1185 total_files: 2,
1186 size_mb: schema.size_mb(),
1187 });
1188 download_file(hf_repo, hf_filename, &model_path).await?;
1189 sink.emit(DownloadEvent::FileCompleted {
1190 filename: "model weights".into(),
1191 });
1192 }
1193 if !crate::download::cache_file_usable(&tokenizer_path) {
1194 info!(model = %schema.name, repo = %tokenizer_repo, "downloading tokenizer");
1195 sink.emit(DownloadEvent::FileStarted {
1196 filename: "tokenizer".into(),
1197 index: 2,
1198 total_files: 2,
1199 size_mb: 0,
1200 });
1201 download_file(tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
1202 sink.emit(DownloadEvent::FileCompleted {
1203 filename: "tokenizer".into(),
1204 });
1205 }
1206
1207 Ok(model_dir)
1208 }
1209 ModelSource::Mlx {
1210 hf_repo,
1211 hf_weight_file,
1212 }
1213 | ModelSource::ManagedVllmMlx {
1214 hf_repo,
1215 hf_weight_file,
1216 } => {
1217 let model_dir = managed_dir_override
1218 .map(Path::to_path_buf)
1219 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1220 let config_path = model_dir.join("config.json");
1221
1222 let is_diffusers = schema.capabilities.iter().any(|c| {
1233 matches!(
1234 c,
1235 ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
1236 )
1237 });
1238
1239 if !force
1247 && mlx_dir_has_weights(&model_dir)
1248 && (is_diffusers || config_path.exists())
1249 {
1250 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1251 info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1252 return Ok(model_dir);
1253 }
1254
1255 if !force {
1258 if let Some(snapshot_dir) =
1259 latest_huggingface_repo_snapshot(hf_repo).filter(|d| mlx_dir_has_weights(d))
1260 {
1261 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &snapshot_dir).await?;
1262 info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1263 return Ok(snapshot_dir);
1264 }
1265 }
1266
1267 std::fs::create_dir_all(&model_dir)?;
1268
1269 info!(model = %schema.name, repo = %hf_repo, "downloading MLX model");
1270
1271 if is_diffusers {
1278 download_repo_snapshot(hf_repo, &model_dir, sink).await?;
1279 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1280 if !mlx_dir_has_weights(&model_dir) {
1281 return Err(InferenceError::DownloadFailed(format!(
1282 "{hf_repo}: snapshot fetched but no component weights found"
1283 )));
1284 }
1285 info!(model = %schema.name, path = %model_dir.display(), "downloaded diffusers model");
1286 return Ok(model_dir);
1287 }
1288
1289 emit_file(sink, "config", 0, schema.size_mb());
1293 download_file(hf_repo, "config.json", &config_path).await?;
1294 download_tokenizer_assets(hf_repo, &model_dir, sink).await;
1295 let tok_config_path = model_dir.join("tokenizer_config.json");
1296 if !crate::download::cache_file_usable(&tok_config_path) {
1297 let _ = download_file(hf_repo, "tokenizer_config.json", &tok_config_path).await;
1298 }
1299
1300 if let Some(ref wf) = hf_weight_file {
1302 let wf_path = model_dir.join(wf);
1303 if !crate::download::cache_file_usable(&wf_path) {
1304 emit_file(sink, "model weights", 0, schema.size_mb());
1305 download_file(hf_repo, wf, &wf_path).await?;
1306 }
1307 } else {
1308 let single = model_dir.join("model.safetensors");
1310 if !crate::download::cache_file_usable(&single) {
1311 emit_file(sink, "model weights", 0, schema.size_mb());
1312 match download_file(hf_repo, "model.safetensors", &single).await {
1313 Ok(()) => {}
1314 Err(_) => {
1315 let index_path = model_dir.join("model.safetensors.index.json");
1317 download_file(hf_repo, "model.safetensors.index.json", &index_path)
1318 .await?;
1319
1320 let index_json: serde_json::Value =
1321 serde_json::from_str(&std::fs::read_to_string(&index_path)?)
1322 .map_err(|e| {
1323 InferenceError::InferenceFailed(format!(
1324 "parse index: {e}"
1325 ))
1326 })?;
1327
1328 if let Some(weight_map) =
1329 index_json.get("weight_map").and_then(|m| m.as_object())
1330 {
1331 let mut files: std::collections::HashSet<String> =
1332 std::collections::HashSet::new();
1333 for filename in weight_map.values() {
1334 if let Some(f) = filename.as_str() {
1335 files.insert(f.to_string());
1336 }
1337 }
1338 let shard_total = files.len() as u32;
1339 for (i, file) in files.iter().enumerate() {
1340 let dest = model_dir.join(file);
1341 if !crate::download::cache_file_usable(&dest) {
1342 info!(file = %file, "downloading weight shard");
1343 sink.emit(DownloadEvent::FileStarted {
1344 filename: format!("weights part {}", i + 1),
1345 index: (i + 1) as u32,
1346 total_files: shard_total,
1347 size_mb: 0,
1348 });
1349 download_file(hf_repo, file, &dest).await?;
1350 sink.emit(DownloadEvent::FileCompleted {
1351 filename: format!("weights part {}", i + 1),
1352 });
1353 }
1354 }
1355 }
1356 }
1357 }
1358 }
1359 }
1360
1361 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1362
1363 let missing = missing_weight_shards(&model_dir);
1375 if !missing.is_empty() {
1376 return Err(InferenceError::DownloadFailed(format!(
1377 "{}: pull finished but {} weight shard(s) are still missing: {}. \
1378 The download was interrupted; re-run the pull to resume it.",
1379 schema.name,
1380 missing.len(),
1381 missing.join(", ")
1382 )));
1383 }
1384 if !mlx_dir_has_weights(&model_dir) {
1385 return Err(InferenceError::DownloadFailed(format!(
1386 "{}: pull finished but no usable weights are present under {}",
1387 schema.name,
1388 model_dir.display()
1389 )));
1390 }
1391 Ok(model_dir)
1392 }
1393 _ => Err(InferenceError::InferenceFailed(format!(
1394 "model {} is not local",
1395 id
1396 ))),
1397 }
1398 }
1399
1400 #[deprecated(note = "use InferenceEngine::remove_model_from_car")]
1404 pub fn remove_local(&mut self, id: &str) -> Result<(), InferenceError> {
1405 Err(InferenceError::InferenceFailed(format!(
1406 "legacy registry removal for {id} is disabled; use receipt-backed model management"
1407 )))
1408 }
1409
1410 pub fn refresh_availability(&mut self) {
1416 let parslee_oauth_available = self.session.available();
1417 self.refresh_availability_with(
1418 parslee_oauth_available,
1419 self.session.signed_out() && self.session.may_forget_session_evidence(),
1420 false,
1421 );
1422 }
1423
1424 pub(crate) fn refresh_routing_availability(
1429 &mut self,
1430 parslee_api_base: Option<&str>,
1431 parslee_signed_out: bool,
1432 ) {
1433 if let Some(api_base) = parslee_api_base {
1434 let api_base = api_base.trim_end_matches('/');
1435 for schema in self.models.values_mut() {
1436 if schema.provider.eq_ignore_ascii_case("parslee") {
1437 if let ModelSource::Proprietary {
1438 provider, endpoint, ..
1439 } = &mut schema.source
1440 {
1441 if provider.eq_ignore_ascii_case("parslee") {
1442 *endpoint = api_base.to_string();
1443 }
1444 }
1445 }
1446 }
1447 }
1448 self.refresh_availability_with(parslee_api_base.is_some(), parslee_signed_out, true);
1449 }
1450
1451 fn refresh_availability_with(
1452 &mut self,
1453 parslee_oauth_available: bool,
1454 clear_parslee_observations: bool,
1455 authoritative_credentials: bool,
1456 ) {
1457 let models_dir = self.models_dir.clone();
1461 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1466 let mlx_vlm_cli_present = crate::backend::mlx_vlm_cli::is_available();
1467 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1468 #[allow(unused_variables)]
1469 let mlx_vlm_cli_present = false;
1470 if clear_parslee_observations {
1475 crate::openrouter::clear_gateway_unconfigured();
1476 crate::parslee_credential::clear_credential_rejected();
1480 }
1481
1482 let mut credential_envs: std::collections::BTreeSet<String> = Default::default();
1500 let mut needs_openrouter = false;
1501 for m in self.models.values() {
1502 match &m.source {
1503 ModelSource::RemoteApi {
1504 protocol: crate::schema::ApiProtocol::OpenRouter,
1505 ..
1506 } => needs_openrouter = true,
1507 ModelSource::RemoteApi { api_key_env, .. } => {
1508 credential_envs.insert(api_key_env.clone());
1509 }
1510 ModelSource::Proprietary { auth, .. } => match auth {
1511 ProprietaryAuth::ApiKeyEnv { env_var }
1512 | ProprietaryAuth::BearerTokenEnv { env_var } => {
1513 credential_envs.insert(env_var.clone());
1514 }
1515 ProprietaryAuth::OAuth2Pkce { .. } => {}
1516 },
1517 _ => {}
1518 }
1519 }
1520 let credential_available: std::collections::HashMap<String, bool> = credential_envs
1521 .into_iter()
1522 .map(|env| {
1523 let available = if authoritative_credentials {
1524 car_secrets::resolve_env_or_keychain(&env).is_some()
1525 } else {
1526 environment_credential_available(&env)
1527 };
1528 (env, available)
1529 })
1530 .collect();
1531 let openrouter_available = needs_openrouter
1534 && if authoritative_credentials {
1535 crate::openrouter::refresh_credential_source().is_some()
1536 } else {
1537 crate::openrouter::credential_source().is_some()
1538 };
1539
1540 for m in self.models.values_mut() {
1541 match &m.source {
1542 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1543 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1552 {
1553 let needs_mlx_vlm = m.tags.iter().any(|t| t == "requires-mlx-vlm");
1560
1561 m.available = if needs_mlx_vlm {
1562 mlx_vlm_cli_present
1563 } else if m.tags.contains(&"speech".to_string()) {
1564 speech_mlx_available()
1565 } else {
1566 let mlx_dir = models_dir.join(&m.name);
1576 mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty()
1577 };
1578 }
1579 #[cfg(not(all(
1580 target_os = "macos",
1581 target_arch = "aarch64",
1582 not(car_skip_mlx)
1583 )))]
1584 {
1585 let _ = hf_repo; m.available = false;
1587 }
1588 }
1589 ModelSource::Local {
1590 hf_repo: local_repo,
1591 ..
1592 } => {
1593 let local_path = models_dir.join(&m.name).join("model.gguf");
1594 #[cfg(not(all(
1609 target_os = "macos",
1610 target_arch = "aarch64",
1611 not(car_skip_mlx)
1612 )))]
1613 {
1614 m.available = local_path.exists() || !local_repo.is_empty();
1615 }
1616 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1621 {
1622 let _ = local_repo;
1623 m.available = local_path.exists();
1624 }
1625 }
1626 ModelSource::WhisperCpp { .. } => {
1627 m.available = true;
1632 }
1633 ModelSource::WindowsSpeech {} => {
1634 #[cfg(target_os = "windows")]
1637 {
1638 m.available = true;
1639 }
1640 #[cfg(not(target_os = "windows"))]
1641 {
1642 m.available = false;
1643 }
1644 }
1645 ModelSource::RemoteApi {
1646 protocol: crate::schema::ApiProtocol::OpenRouter,
1647 ..
1648 } => {
1649 m.available = openrouter_available;
1650 }
1651 ModelSource::RemoteApi { api_key_env, .. } => {
1652 m.available = credential_available
1655 .get(api_key_env)
1656 .copied()
1657 .unwrap_or(false);
1658 }
1659 ModelSource::CodexCli { .. } => {
1660 m.available = crate::backend::codex_cli::is_available();
1661 }
1662 ModelSource::Ollama { .. } => {
1663 m.available = true;
1665 }
1666 ModelSource::VllmMlx { .. } => {
1667 m.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || m.available;
1671 }
1672 ModelSource::Proprietary { provider, auth, .. } => {
1673 m.available = proprietary_auth_available(
1674 &m.id,
1675 &m.provider,
1676 provider,
1677 auth,
1678 parslee_oauth_available,
1679 &credential_available,
1680 );
1681 }
1682 ModelSource::AppleFoundationModels { .. } => {
1683 #[cfg(any(
1690 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
1691 all(target_os = "ios", target_arch = "aarch64")
1692 ))]
1693 {
1694 m.available = crate::backend::foundation_models::is_available();
1695 if crate::backend::foundation_models::supports_parallel_tool_calls()
1708 && !m.capabilities.contains(&ModelCapability::MultiToolCall)
1709 {
1710 m.capabilities.push(ModelCapability::MultiToolCall);
1711 }
1712 if crate::backend::foundation_models::supports_vision()
1721 && !m.capabilities.contains(&ModelCapability::Vision)
1722 {
1723 m.capabilities.push(ModelCapability::Vision);
1724 }
1725 if let Some(window) = crate::backend::foundation_models::context_size() {
1733 m.context_length = window as usize;
1734 }
1735 }
1736 #[cfg(not(any(
1737 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
1738 all(target_os = "ios", target_arch = "aarch64")
1739 )))]
1740 {
1741 m.available = false;
1742 }
1743 }
1744 ModelSource::Delegated { .. } => {
1745 m.available = crate::runner::current_inference_runner().is_some();
1750 }
1751 }
1752 m.weights_ready = physical_weights_ready(m, &models_dir);
1753 }
1754 }
1755
1756 pub fn save_user_config(&self) -> Result<(), InferenceError> {
1758 let mut user_models: Vec<ModelSchema> = self
1759 .user_config_ids
1760 .iter()
1761 .filter_map(|id| self.models.get(id))
1762 .cloned()
1763 .map(|mut model| {
1764 model.mark_user_registered();
1767 model
1768 })
1769 .collect();
1770 user_models.sort_by(|a, b| a.id.cmp(&b.id));
1771
1772 for model in &user_models {
1773 crate::catalog_identity::row_digest(model).map_err(|error| {
1774 InferenceError::InferenceFailed(format!(
1775 "refuse to persist model without canonical catalog identity: {error}"
1776 ))
1777 })?;
1778 }
1779
1780 let json = serde_json::to_string_pretty(&user_models)
1781 .map_err(|e| InferenceError::InferenceFailed(format!("serialize: {e}")))?;
1782 std::fs::write(&self.user_config_path, json)?;
1783 Ok(())
1784 }
1785
1786 pub fn load_user_config(&mut self) -> Result<(), InferenceError> {
1788 if !self.user_config_path.exists() {
1789 return Ok(());
1790 }
1791
1792 let json = std::fs::read_to_string(&self.user_config_path)?;
1793 let models: Vec<ModelSchema> = serde_json::from_str(&json)
1794 .map_err(|e| InferenceError::InferenceFailed(format!("parse models.json: {e}")))?;
1795
1796 for m in models {
1797 self.register_user_model(m);
1800 }
1801 Ok(())
1802 }
1803
1804 pub fn models_dir(&self) -> &Path {
1806 &self.models_dir
1807 }
1808
1809 pub fn ready_without_download(&self, id: &str) -> Option<bool> {
1817 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
1818 Some(match &schema.source {
1819 ModelSource::Local { .. } => {
1820 let model_dir = self.models_dir.join(&schema.name);
1821 crate::download::cache_file_usable(&model_dir.join("model.gguf"))
1822 && crate::download::cache_file_usable(&model_dir.join("tokenizer.json"))
1823 }
1824 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1825 let managed_dir = self.models_dir.join(&schema.name);
1826 let managed_ready = mlx_snapshot_complete(schema, &managed_dir)
1827 && crate::download::cache_file_usable(&managed_dir.join("tokenizer.json"));
1828 let snapshot_ready =
1829 latest_huggingface_repo_snapshot(hf_repo).is_some_and(|snapshot| {
1830 mlx_snapshot_complete(schema, &snapshot)
1831 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
1832 });
1833 managed_ready || snapshot_ready
1834 }
1835 ModelSource::WindowsSpeech {} => true, ModelSource::WhisperCpp { model } => {
1837 car_whisper::model_cached(model)
1841 }
1842 ModelSource::RemoteApi { .. }
1843 | ModelSource::CodexCli { .. }
1844 | ModelSource::Ollama { .. }
1845 | ModelSource::VllmMlx { .. }
1846 | ModelSource::AppleFoundationModels { .. }
1847 | ModelSource::Proprietary { .. }
1848 | ModelSource::Delegated { .. } => true,
1849 })
1850 }
1851
1852 pub fn existing_local_artifact(&self, id: &str) -> Option<PathBuf> {
1856 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
1857 let managed = self.models_dir.join(&schema.name);
1858 if std::fs::symlink_metadata(&managed).is_ok()
1859 && self.ready_without_download(&schema.id) == Some(true)
1860 {
1861 return Some(managed);
1862 }
1863 match &schema.source {
1864 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1865 latest_huggingface_repo_snapshot(hf_repo).filter(|snapshot| {
1866 mlx_snapshot_complete(schema, snapshot)
1867 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
1868 })
1869 }
1870 _ => None,
1871 }
1872 }
1873
1874 fn load_builtin_catalog(&mut self) {
1876 for schema in builtin_catalog() {
1877 let id = schema.id.clone();
1878 if self.register_project_model(schema) {
1879 self.builtin_model_ids.insert(id);
1880 }
1881 }
1882 }
1883}
1884
1885fn synthesize_local_schema(name: &str, dir: &Path) -> Option<ModelSchema> {
1895 let lower = name.to_ascii_lowercase();
1896
1897 const NON_TEXT_HINTS: &[&str] = &[
1900 "vad",
1901 "whisper",
1902 "parakeet",
1903 "kokoro",
1904 "tts",
1905 "stt",
1906 "flux",
1907 "ltx",
1908 "yume",
1909 "sd-",
1910 "stable-diffusion",
1911 "wan",
1912 "mochi",
1913 "sana",
1914 "diffusion",
1915 ];
1916 if NON_TEXT_HINTS.iter().any(|h| lower.contains(h)) {
1917 return None;
1918 }
1919
1920 let capabilities: Vec<ModelCapability> =
1923 if lower.contains("embedding") || lower.contains("embed") {
1924 vec![ModelCapability::Embed]
1925 } else if lower.contains("reranker") || lower.contains("rerank") {
1926 vec![ModelCapability::Rerank]
1927 } else {
1928 vec![
1929 ModelCapability::Generate,
1930 ModelCapability::Code,
1931 ModelCapability::Reasoning,
1932 ]
1933 };
1934
1935 let config_path = dir.join("config.json");
1937 let has_safetensors =
1938 dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists();
1939
1940 let (source, context_length, quantization) = if config_path.exists() && has_safetensors {
1941 let cfg: serde_json::Value = std::fs::read_to_string(&config_path)
1943 .ok()
1944 .and_then(|s| serde_json::from_str(&s).ok())?;
1945 let model_type = cfg
1946 .get("model_type")
1947 .and_then(|v| v.as_str())
1948 .unwrap_or("")
1949 .to_ascii_lowercase();
1950 const KNOWN_LLM_TYPES: &[&str] = &[
1951 "qwen",
1952 "qwen2",
1953 "qwen3",
1954 "qwen3_moe",
1955 "llama",
1956 "mistral",
1957 "mixtral",
1958 "gemma",
1959 "gemma2",
1960 "gemma3",
1961 "gemma4_unified",
1962 "gemma4_unified_text",
1963 "phi",
1964 "phi3",
1965 "phimoe",
1966 "starcoder2",
1967 "deepseek",
1968 "deepseek_v2",
1969 "internlm2",
1970 "cohere",
1971 "olmo",
1972 ];
1973 if !KNOWN_LLM_TYPES.iter().any(|t| model_type == *t) {
1974 return None;
1975 }
1976 let ctx = cfg
1977 .get("max_position_embeddings")
1978 .and_then(|v| v.as_u64())
1979 .unwrap_or(32_768) as usize;
1980 let quant = cfg
1986 .get("quantization")
1987 .filter(|q| q.is_object())
1988 .and_then(|q| {
1989 let bits = q
1990 .get("bits")
1991 .and_then(|b| b.as_u64())
1992 .and_then(|b| u8::try_from(b).ok());
1993 let group_size = q
1994 .get("group_size")
1995 .and_then(|g| g.as_u64())
1996 .and_then(|g| u32::try_from(g).ok());
1997 let mode = q.get("mode").and_then(|m| m.as_str());
1998 crate::schema::Quantization::from_mlx_config(bits, group_size, mode)
1999 });
2000 (
2001 serde_json::json!({ "type": "mlx", "hf_repo": "" }),
2002 ctx,
2003 quant,
2004 )
2005 } else {
2006 let gguf = std::fs::read_dir(dir).ok().and_then(|rd| {
2007 rd.flatten().map(|e| e.path()).find(|p| {
2008 p.extension()
2009 .and_then(|x| x.to_str())
2010 .is_some_and(|x| x.eq_ignore_ascii_case("gguf"))
2011 })
2012 })?;
2013 let filename = gguf
2015 .file_name()
2016 .and_then(|n| n.to_str())
2017 .unwrap_or("model.gguf")
2018 .to_string();
2019 let quant = crate::schema::Quantization::from_gguf_filename(&filename);
2023 (
2024 serde_json::json!({
2025 "type": "local",
2026 "hf_repo": "",
2027 "hf_filename": filename,
2028 "tokenizer_repo": "",
2029 }),
2030 4_096,
2031 quant,
2032 )
2033 };
2034
2035 let id = format!("local/{}", lower.replace(['/', ' '], "-"));
2036 serde_json::from_value(serde_json::json!({
2037 "id": id,
2038 "name": name,
2039 "provider": "local",
2040 "family": "local",
2041 "capabilities": capabilities,
2042 "context_length": context_length,
2043 "quantization": quantization,
2044 "source": source,
2045 "tags": ["auto-discovered"],
2046 "trust_tier": "community",
2047 }))
2048 .ok()
2049}
2050
2051#[allow(dead_code)] fn speech_mlx_available() -> bool {
2053 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2056 {
2057 true
2058 }
2059
2060 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2062 {
2063 let runtime_root = speech_runtime_root();
2068 crate::managed_venv::venv_program(&runtime_root, "mlx_audio.stt.generate").exists()
2069 || crate::managed_venv::venv_program(&runtime_root, "mlx_audio.tts.generate").exists()
2070 }
2071}
2072
2073#[allow(dead_code)] fn speech_runtime_root() -> PathBuf {
2075 if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
2076 if !path.trim().is_empty() {
2077 return PathBuf::from(path);
2078 }
2079 }
2080 std::env::var_os("HOME")
2081 .or_else(|| std::env::var_os("USERPROFILE"))
2082 .map(PathBuf::from)
2083 .unwrap_or_else(|| PathBuf::from("."))
2084 .join(".car")
2085 .join("speech-runtime")
2086}
2087
2088#[derive(Debug, Clone, Serialize, Deserialize)]
2090pub struct ModelInfo {
2091 pub id: String,
2092 pub name: String,
2093 pub provider: String,
2094 pub capabilities: Vec<ModelCapability>,
2095 pub param_count: String,
2096 pub size_mb: u64,
2097 pub context_length: usize,
2098 pub available: bool,
2099 pub is_local: bool,
2100 #[serde(default)]
2104 pub operator_managed_external_runtime: bool,
2105 #[serde(default)]
2113 pub weights_ready: bool,
2114 #[serde(default)]
2127 pub downloads_weights: bool,
2128 #[serde(default)]
2132 pub max_output_tokens: Option<usize>,
2133 #[serde(default)]
2137 pub public_benchmarks: Vec<crate::schema::BenchmarkScore>,
2138 #[serde(default)]
2153 pub cost: crate::schema::CostModel,
2154 #[serde(default = "default_true")]
2155 pub car_enabled: bool,
2156 #[serde(default)]
2157 pub can_remove: bool,
2158 #[serde(default)]
2159 pub in_use: bool,
2160 #[serde(default)]
2161 pub management_evidence: Option<String>,
2162 #[serde(default)]
2173 pub fit: crate::recommend::ModelFitStatus,
2174 #[serde(default)]
2177 pub estimated_peak_mb: Option<u64>,
2178 #[serde(default = "default_true")]
2183 pub platform_compatible: bool,
2184 #[serde(default)]
2188 pub deprecated: bool,
2189 #[serde(default)]
2195 pub family: Option<String>,
2196 #[serde(default)]
2200 pub version: Option<String>,
2201}
2202
2203fn default_true() -> bool {
2204 true
2205}
2206
2207impl ModelInfo {
2208 pub fn with_fit(mut self, fit: crate::recommend::ModelFit) -> Self {
2212 self.fit = fit.fit;
2213 self.estimated_peak_mb = fit.estimated_peak_mb;
2214 self.platform_compatible = fit.platform_compatible;
2215 self
2216 }
2217}
2218
2219impl From<&ModelSchema> for ModelInfo {
2220 fn from(s: &ModelSchema) -> Self {
2221 ModelInfo {
2222 id: s.id.clone(),
2223 name: s.name.clone(),
2224 provider: s.provider.clone(),
2225 capabilities: s.capabilities.clone(),
2226 param_count: s.param_count.clone(),
2227 size_mb: s.size_mb(),
2228 context_length: s.context_length,
2229 available: s.available_now(),
2230 is_local: s.is_local(),
2231 operator_managed_external_runtime: matches!(s.source, ModelSource::VllmMlx { .. }),
2232 weights_ready: s.weights_ready,
2233 downloads_weights: s.downloads_weights(),
2234 max_output_tokens: s.max_output_tokens,
2235 public_benchmarks: s.public_benchmarks.clone(),
2236 cost: s.cost.clone(),
2241 car_enabled: true,
2242 can_remove: false,
2243 in_use: false,
2244 management_evidence: None,
2245 fit: crate::recommend::ModelFitStatus::Unknown,
2250 estimated_peak_mb: None,
2251 platform_compatible: true,
2252 deprecated: s.deprecated,
2253 family: s.is_local().then(|| s.family.clone()),
2256 version: s.is_local().then(|| s.version.clone()),
2257 }
2258 }
2259}
2260
2261fn emit_file(sink: &ProgressSink, name: &str, index: u32, size_mb: u64) {
2266 sink.emit(DownloadEvent::FileStarted {
2267 filename: name.to_string(),
2268 index,
2269 total_files: 0,
2270 size_mb,
2271 });
2272}
2273
2274async fn download_repo_snapshot(
2283 repo: &str,
2284 model_dir: &Path,
2285 sink: &ProgressSink,
2286) -> Result<(), InferenceError> {
2287 #[derive(serde::Deserialize)]
2288 struct RepoInfo {
2289 siblings: Vec<Sibling>,
2290 }
2291 #[derive(serde::Deserialize)]
2292 struct Sibling {
2293 rfilename: String,
2294 }
2295 let url = format!("https://huggingface.co/api/models/{repo}");
2296 let info: RepoInfo = crate::tls_client::model_download_client()
2301 .get(&url)
2302 .send()
2303 .await
2304 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2305 .error_for_status()
2306 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2307 .json()
2308 .await
2309 .map_err(|e| InferenceError::DownloadFailed(format!("parse {repo} file list: {e}")))?;
2310
2311 let files: Vec<String> = info
2312 .siblings
2313 .into_iter()
2314 .map(|s| s.rfilename)
2315 .filter(|f| !f.starts_with('.') && !f.to_ascii_lowercase().ends_with(".md"))
2316 .collect();
2317 if files.is_empty() {
2318 return Err(InferenceError::DownloadFailed(format!(
2319 "{repo}: repo lists no downloadable files"
2320 )));
2321 }
2322
2323 let total = files.len() as u32;
2324 for (i, fname) in files.iter().enumerate() {
2325 let dest = model_dir.join(fname);
2326 if crate::download::cache_file_usable(&dest) {
2327 continue;
2328 }
2329 if let Some(parent) = dest.parent() {
2330 std::fs::create_dir_all(parent)?;
2331 }
2332 sink.emit(DownloadEvent::FileStarted {
2333 filename: fname.clone(),
2334 index: (i + 1) as u32,
2335 total_files: total,
2336 size_mb: 0,
2337 });
2338 download_file(repo, fname, &dest).await?;
2339 sink.emit(DownloadEvent::FileCompleted {
2340 filename: fname.clone(),
2341 });
2342 }
2343 Ok(())
2344}
2345
2346const TOKENIZER_FILENAMES: &[&str] = &[
2357 "tokenizer.json",
2358 "vocab.json",
2359 "merges.txt",
2360 "tokenizer.model",
2361 "tokenizer.vocab",
2362 "vocab.txt",
2363];
2364
2365async fn download_tokenizer_assets(hf_repo: &str, model_dir: &Path, sink: &ProgressSink) {
2385 if TOKENIZER_FILENAMES
2387 .iter()
2388 .any(|f| crate::download::cache_file_usable(&model_dir.join(f)))
2389 {
2390 return;
2391 }
2392 emit_file(sink, "tokenizer", 0, 0);
2393 let mut fetched: Vec<&str> = Vec::new();
2394 for name in TOKENIZER_FILENAMES {
2395 let dest = model_dir.join(name);
2396 if crate::download::cache_file_usable(&dest) {
2397 continue;
2398 }
2399 if download_file(hf_repo, name, &dest).await.is_ok() {
2400 fetched.push(name);
2401 }
2402 }
2403 if fetched.is_empty() {
2404 tracing::debug!(
2406 repo = %hf_repo,
2407 "no tokenizer assets in this repo; continuing (the backend may not need one)"
2408 );
2409 } else {
2410 tracing::debug!(repo = %hf_repo, files = ?fetched, "fetched tokenizer assets");
2411 }
2412}
2413
2414async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
2415 if crate::download::cache_file_usable(dest) {
2419 return Ok(());
2420 }
2421
2422 let api = hf_hub::api::tokio::Api::new()
2423 .map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
2424
2425 let repo = api.model(repo.to_string());
2426 let path = repo
2427 .get(filename)
2428 .await
2429 .map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
2430
2431 install_fetched_file(&path, dest)
2432}
2433
2434fn install_fetched_file(src: &Path, dest: &Path) -> Result<(), InferenceError> {
2436 if crate::download::cache_file_usable(dest) {
2437 return Ok(());
2438 }
2439
2440 match std::fs::symlink_metadata(dest) {
2441 Ok(_) => std::fs::remove_file(dest).map_err(|e| {
2442 InferenceError::DownloadFailed(format!(
2443 "remove unusable destination {}: {e}",
2444 dest.display()
2445 ))
2446 })?,
2447 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2448 Err(error) => {
2449 return Err(InferenceError::DownloadFailed(format!(
2450 "inspect destination {}: {error}",
2451 dest.display()
2452 )));
2453 }
2454 }
2455
2456 #[cfg(unix)]
2460 {
2461 if std::os::unix::fs::symlink(src, dest).is_ok() {
2462 return Ok(());
2463 }
2464 }
2465
2466 static INSTALL_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2467 let sequence = INSTALL_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2468 let file_name = dest
2469 .file_name()
2470 .and_then(|name| name.to_str())
2471 .unwrap_or("download");
2472 let temp = dest.with_file_name(format!(
2473 ".{file_name}.car-install-{}-{sequence}.tmp",
2474 std::process::id()
2475 ));
2476 let _ = std::fs::remove_file(&temp);
2477 std::fs::copy(src, &temp).map_err(|error| {
2478 InferenceError::DownloadFailed(format!(
2479 "copy to temporary destination {}: {error}",
2480 temp.display()
2481 ))
2482 })?;
2483 if let Err(error) = std::fs::rename(&temp, dest) {
2484 let _ = std::fs::remove_file(&temp);
2485 return Err(InferenceError::DownloadFailed(format!(
2486 "publish downloaded file at {}: {error}",
2487 dest.display()
2488 )));
2489 }
2490 Ok(())
2491}
2492
2493fn auxiliary_mlx_files_missing(model_name: &str, hf_repo: &str, model_dir: &Path) -> bool {
2494 (hf_repo == "mlx-community/Flux-1.lite-8B-MLX-Q4" || model_name == "Flux-1.lite-8B-MLX-Q4")
2495 && !crate::download::cache_file_usable(
2496 &model_dir.join("tokenizer_2").join("tokenizer.json"),
2497 )
2498}
2499
2500async fn ensure_auxiliary_mlx_files(
2501 model_name: &str,
2502 hf_repo: &str,
2503 model_dir: &Path,
2504) -> Result<(), InferenceError> {
2505 if auxiliary_mlx_files_missing(model_name, hf_repo, model_dir) {
2506 let t5_tokenizer_path = model_dir.join("tokenizer_2").join("tokenizer.json");
2507 std::fs::create_dir_all(
2508 t5_tokenizer_path
2509 .parent()
2510 .ok_or_else(|| InferenceError::InferenceFailed("invalid tokenizer path".into()))?,
2511 )?;
2512 info!(
2513 path = %t5_tokenizer_path.display(),
2514 "downloading missing Flux tokenizer_2/tokenizer.json from base model"
2515 );
2516 download_file(
2517 "Freepik/flux.1-lite-8B",
2518 "tokenizer_2/tokenizer.json",
2519 &t5_tokenizer_path,
2520 )
2521 .await?;
2522 }
2523 Ok(())
2524}
2525
2526fn mlx_auxiliary_ready_without_download(model_name: &str, model_dir: &Path) -> bool {
2527 if model_name == "Flux-1.lite-8B-MLX-Q4" {
2528 return crate::download::cache_file_usable(
2529 &model_dir.join("tokenizer_2").join("tokenizer.json"),
2530 );
2531 }
2532 true
2533}
2534
2535fn physical_weights_ready(schema: &ModelSchema, models_dir: &Path) -> bool {
2538 physical_weights_ready_with_huggingface_hub(schema, models_dir, None)
2539}
2540
2541pub(crate) fn physical_weights_ready_with_huggingface_hub(
2542 schema: &ModelSchema,
2543 models_dir: &Path,
2544 huggingface_hub_root: Option<&Path>,
2545) -> bool {
2546 match &schema.source {
2547 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2548 let managed_dir = models_dir.join(&schema.name);
2549 if mlx_snapshot_complete(schema, &managed_dir) {
2550 return true;
2551 }
2552 let shared_snapshot = match huggingface_hub_root {
2553 Some(root) => latest_huggingface_repo_snapshot_in(
2554 &root.join(format!("models--{}", hf_repo.replace('/', "--"))),
2555 ),
2556 None => latest_huggingface_repo_snapshot(hf_repo),
2557 };
2558 shared_snapshot
2559 .as_deref()
2560 .is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
2561 }
2562 ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
2563 ModelSource::Local { .. } => {
2564 crate::download::cache_file_usable(&models_dir.join(&schema.name).join("model.gguf"))
2565 }
2566 ModelSource::WindowsSpeech {}
2570 | ModelSource::AppleFoundationModels { .. }
2571 | ModelSource::VllmMlx { .. }
2572 | ModelSource::Ollama { .. }
2573 | ModelSource::RemoteApi { .. }
2574 | ModelSource::CodexCli { .. }
2575 | ModelSource::Proprietary { .. }
2576 | ModelSource::Delegated { .. } => true,
2577 }
2578}
2579
2580#[cfg(test)]
2581fn mlx_weights_ready_at(
2582 schema: &ModelSchema,
2583 managed_dir: &Path,
2584 shared_snapshot: Option<&Path>,
2585) -> bool {
2586 mlx_snapshot_complete(schema, managed_dir)
2587 || shared_snapshot.is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
2588}
2589
2590fn mlx_snapshot_complete(schema: &ModelSchema, dir: &Path) -> bool {
2597 let is_diffusers = schema.capabilities.iter().any(|capability| {
2598 matches!(
2599 capability,
2600 ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
2601 )
2602 });
2603 let metadata_ready =
2604 is_diffusers || crate::download::cache_file_usable(&dir.join("config.json"));
2605
2606 metadata_ready
2607 && mlx_dir_has_weights(dir)
2608 && mlx_auxiliary_ready_without_download(&schema.name, dir)
2609}
2610
2611pub(crate) fn mlx_dir_has_weights(dir: &Path) -> bool {
2633 let index = dir.join("model.safetensors.index.json");
2637 if index.is_file() {
2638 return sharded_weight_files(&index).is_some_and(|required| {
2639 !required.is_empty()
2640 && required
2641 .iter()
2642 .all(|shard| crate::download::cache_file_usable(&dir.join(shard)))
2643 });
2644 }
2645 mlx_dir_has_weights_depth(dir, 0)
2646}
2647
2648pub(crate) fn missing_weight_shards(dir: &Path) -> Vec<String> {
2660 let index = dir.join("model.safetensors.index.json");
2661 if !index.is_file() {
2662 return Vec::new();
2663 }
2664 let Some(required) = sharded_weight_files(&index) else {
2665 return Vec::new();
2668 };
2669 required
2670 .into_iter()
2671 .filter(|shard| !dir.join(shard).exists())
2672 .collect()
2673}
2674
2675fn sharded_weight_files(index: &Path) -> Option<Vec<String>> {
2682 let raw = std::fs::read_to_string(index).ok()?;
2683 let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
2684 let map = parsed.get("weight_map")?.as_object()?;
2685 let mut files: Vec<String> = map
2686 .values()
2687 .filter_map(|v| v.as_str().map(str::to_string))
2688 .collect();
2689 files.sort();
2690 files.dedup();
2691 Some(files)
2692}
2693
2694fn mlx_dir_has_weights_depth(dir: &Path, depth: usize) -> bool {
2705 if depth > 4 {
2706 return false;
2707 }
2708 let Ok(rd) = std::fs::read_dir(dir) else {
2709 return false;
2710 };
2711 rd.flatten().any(|e| {
2712 let p = e.path();
2713 let is_symlink = std::fs::symlink_metadata(&p)
2714 .map(|m| m.file_type().is_symlink())
2715 .unwrap_or(true);
2716 if p.is_dir() {
2717 !is_symlink && mlx_dir_has_weights_depth(&p, depth + 1)
2718 } else {
2719 p.extension().and_then(|x| x.to_str()) == Some("safetensors")
2723 && crate::download::cache_file_usable(&p)
2724 }
2725 })
2726}
2727
2728#[allow(dead_code)] fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
2730 latest_huggingface_repo_snapshot(repo_id).is_some()
2731}
2732
2733pub(crate) fn huggingface_cache_root() -> PathBuf {
2734 std::env::var("HF_HOME")
2735 .map(PathBuf::from)
2736 .unwrap_or_else(|_| {
2737 std::env::var_os("HOME")
2738 .or_else(|| std::env::var_os("USERPROFILE"))
2739 .map(PathBuf::from)
2740 .unwrap_or_else(|| PathBuf::from("."))
2741 .join(".cache")
2742 .join("huggingface")
2743 })
2744 .join("hub")
2745}
2746
2747pub(crate) fn huggingface_repo_dir(repo_id: &str) -> PathBuf {
2748 huggingface_cache_root().join(format!("models--{}", repo_id.replace('/', "--")))
2749}
2750
2751fn resolve_huggingface_ref_snapshot(repo_dir: &Path, name: &str) -> Option<PathBuf> {
2752 let sha = std::fs::read_to_string(repo_dir.join("refs").join(name))
2753 .ok()?
2754 .trim()
2755 .to_string();
2756 if sha.is_empty() {
2757 return None;
2758 }
2759
2760 let snapshot = repo_dir.join("snapshots").join(sha);
2761 if snapshot_looks_ready(&snapshot) {
2762 Some(snapshot)
2763 } else {
2764 None
2765 }
2766}
2767
2768fn latest_huggingface_repo_snapshot(repo_id: &str) -> Option<PathBuf> {
2769 let repo_dir = huggingface_repo_dir(repo_id);
2770 latest_huggingface_repo_snapshot_in(&repo_dir)
2771}
2772
2773fn latest_huggingface_repo_snapshot_in(repo_dir: &Path) -> Option<PathBuf> {
2774 if let Some(snapshot) = resolve_huggingface_ref_snapshot(repo_dir, "main") {
2775 return Some(snapshot);
2776 }
2777
2778 let snapshots = repo_dir.join("snapshots");
2779 let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(snapshots)
2780 .ok()?
2781 .filter_map(Result::ok)
2782 .map(|e| e.path())
2783 .filter(|p| p.is_dir() && snapshot_looks_ready(p))
2784 .map(|path| {
2785 let modified = path
2786 .metadata()
2787 .and_then(|metadata| metadata.modified())
2788 .unwrap_or(SystemTime::UNIX_EPOCH);
2789 (modified, path)
2790 })
2791 .collect();
2792 candidates.sort();
2793 candidates.pop().map(|(_, path)| path)
2794}
2795
2796fn snapshot_looks_ready(path: &Path) -> bool {
2797 if path.join("config.json").exists() || path.join("model_index.json").exists() {
2798 return true;
2799 }
2800 snapshot_contains_ext(path, "safetensors")
2801}
2802
2803fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
2804 let Ok(entries) = std::fs::read_dir(root) else {
2805 return false;
2806 };
2807 entries.filter_map(Result::ok).any(|entry| {
2808 let path = entry.path();
2809 if path.is_dir() {
2810 snapshot_contains_ext(&path, ext)
2811 } else {
2812 let ext_matches = path
2813 .extension()
2814 .and_then(|value| value.to_str())
2815 .map(|value| value.eq_ignore_ascii_case(ext))
2816 .unwrap_or(false);
2817 ext_matches && crate::download::cache_file_usable(&path)
2821 }
2822 })
2823}
2824
2825const BUILTIN_CATALOG_JSON: &str = include_str!("builtin_catalog.json");
2834
2835static BUILTIN_CATALOG: std::sync::LazyLock<Vec<ModelSchema>> = std::sync::LazyLock::new(|| {
2836 serde_json::from_str(BUILTIN_CATALOG_JSON)
2837 .expect("builtin_catalog.json failed to parse — fix the JSON, not this code")
2838});
2839
2840pub(crate) fn builtin_catalog() -> Vec<ModelSchema> {
2841 let mut catalog = BUILTIN_CATALOG.clone();
2842 catalog.extend(crate::openrouter::builtin_schemas());
2843 catalog
2844}
2845
2846#[doc(hidden)]
2849pub fn builtin_catalog_with_huggingface_hub_for_testing(
2850 models_dir: &Path,
2851 huggingface_hub_root: &Path,
2852) -> Vec<ModelSchema> {
2853 let mut catalog = builtin_catalog();
2854 for schema in &mut catalog {
2855 schema.weights_ready = physical_weights_ready_with_huggingface_hub(
2856 schema,
2857 models_dir,
2858 Some(huggingface_hub_root),
2859 );
2860 }
2861 catalog
2862}
2863
2864#[cfg(test)]
2865mod tests {
2866 use crate::openrouter::StateRootScope;
2867
2868 #[test]
2876 fn a_sharded_model_missing_one_shard_is_not_installed() {
2877 let tmp = tempfile::tempdir().unwrap();
2878 let dir = tmp.path();
2879 std::fs::write(
2880 dir.join("model.safetensors.index.json"),
2881 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
2882 "b":"model-00002-of-00002.safetensors"}}"#,
2883 )
2884 .unwrap();
2885 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
2887 assert!(
2888 !mlx_dir_has_weights(dir),
2889 "a missing shard must read as not-installed, or pull silently no-ops"
2890 );
2891
2892 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
2894 assert!(
2895 mlx_dir_has_weights(dir),
2896 "a complete shard set must read as installed"
2897 );
2898 }
2899
2900 #[test]
2908 fn missing_shards_are_reported_by_name() {
2909 let tmp = tempfile::tempdir().unwrap();
2910 let dir = tmp.path();
2911 std::fs::write(
2912 dir.join("model.safetensors.index.json"),
2913 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
2914 "b":"model-00002-of-00002.safetensors"}}"#,
2915 )
2916 .unwrap();
2917 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
2918
2919 assert_eq!(
2920 missing_weight_shards(dir),
2921 vec!["model-00001-of-00002.safetensors".to_string()],
2922 "the absent shard must be named, not just counted"
2923 );
2924
2925 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
2926 assert!(
2927 missing_weight_shards(dir).is_empty(),
2928 "a complete shard set must report nothing missing"
2929 );
2930 }
2931
2932 #[test]
2936 fn missing_shards_is_empty_without_an_index() {
2937 let tmp = tempfile::tempdir().unwrap();
2938 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
2939 assert!(missing_weight_shards(tmp.path()).is_empty());
2940
2941 let bad = tempfile::tempdir().unwrap();
2944 std::fs::write(bad.path().join("model.safetensors.index.json"), b"not json").unwrap();
2945 assert!(missing_weight_shards(bad.path()).is_empty());
2946 }
2947
2948 #[test]
2950 fn a_single_file_model_still_counts_without_an_index() {
2951 let tmp = tempfile::tempdir().unwrap();
2952 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
2953 assert!(mlx_dir_has_weights(tmp.path()));
2954 }
2955
2956 #[test]
2959 fn an_unparseable_index_fails_closed() {
2960 let tmp = tempfile::tempdir().unwrap();
2961 std::fs::write(
2962 tmp.path().join("model.safetensors.index.json"),
2963 b"{not-json",
2964 )
2965 .unwrap();
2966 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
2967 assert!(
2968 !mlx_dir_has_weights(tmp.path()),
2969 "an unreadable index must not fall back to a stray weight"
2970 );
2971 }
2972
2973 use super::*;
2974 use tempfile::TempDir;
2975
2976 #[test]
2977 fn mlx_dir_has_weights_detects_completeness() {
2978 let tmp = TempDir::new().unwrap();
2979 let dir = tmp.path();
2980
2981 std::fs::write(dir.join("config.json"), "{}").unwrap();
2983 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
2984 assert!(
2985 !mlx_dir_has_weights(dir),
2986 "config-only stub must not count as installed"
2987 );
2988
2989 std::fs::write(dir.join("model.safetensors.index.json"), "{}").unwrap();
2991 assert!(!mlx_dir_has_weights(dir), "index.json alone is not weights");
2992
2993 std::fs::remove_file(dir.join("model.safetensors.index.json")).unwrap();
2995 std::fs::write(dir.join("model.safetensors"), b"\x00\x01\x02").unwrap();
2996 assert!(mlx_dir_has_weights(dir));
2997 }
2998
2999 #[test]
3000 fn mlx_dir_has_weights_handles_sharded_and_dangling_symlinks() {
3001 let sharded = TempDir::new().unwrap();
3002 std::fs::write(sharded.path().join("config.json"), "{}").unwrap();
3003 std::fs::write(
3004 sharded.path().join("model-00001-of-00002.safetensors"),
3005 b"\x00",
3006 )
3007 .unwrap();
3008 assert!(mlx_dir_has_weights(sharded.path()), "sharded shard counts");
3009
3010 #[cfg(unix)]
3013 {
3014 let dangling = TempDir::new().unwrap();
3015 std::fs::write(dangling.path().join("config.json"), "{}").unwrap();
3016 std::os::unix::fs::symlink(
3017 dangling.path().join("does-not-exist"),
3018 dangling.path().join("model.safetensors"),
3019 )
3020 .unwrap();
3021 assert!(
3022 !mlx_dir_has_weights(dangling.path()),
3023 "dangling weight symlink must count as absent"
3024 );
3025 }
3026 }
3027
3028 struct TestRegistry {
3050 registry: UnifiedRegistry,
3051 _tmp: TempDir,
3055 _environment: tokio::sync::MutexGuard<'static, ()>,
3056 }
3057
3058 impl std::ops::Deref for TestRegistry {
3059 type Target = UnifiedRegistry;
3060
3061 fn deref(&self) -> &Self::Target {
3062 &self.registry
3063 }
3064 }
3065
3066 impl std::ops::DerefMut for TestRegistry {
3067 fn deref_mut(&mut self) -> &mut Self::Target {
3068 &mut self.registry
3069 }
3070 }
3071
3072 fn test_registry() -> TestRegistry {
3073 let _environment = crate::openrouter::test_environment_scope();
3074 let tmp = TempDir::new().unwrap();
3075 let registry = UnifiedRegistry::new_with_state_root(
3076 tmp.path().to_path_buf(),
3077 tmp.path().join("models"),
3078 );
3079 TestRegistry {
3080 registry,
3081 _tmp: tmp,
3082 _environment,
3083 }
3084 }
3085
3086 fn test_generate_schema(id: &str, name: &str, source: ModelSource) -> ModelSchema {
3087 ModelSchema {
3088 id: id.into(),
3089 name: name.into(),
3090 provider: "local".into(),
3091 family: "qwen3".into(),
3092 version: "test".into(),
3093 capabilities: vec![ModelCapability::Generate],
3094 context_length: 4096,
3095 max_output_tokens: None,
3096 param_count: String::new(),
3097 quantization: None,
3098 performance: PerformanceEnvelope::default(),
3099 cost: CostModel::default(),
3100 source,
3101 tags: vec![],
3102 supported_params: vec![],
3103 public_benchmarks: vec![],
3104 trust_tier: crate::schema::TrustTier::Curated,
3105 deprecated: false,
3106 available: false,
3107 weights_ready: false,
3108 }
3109 }
3110
3111 #[test]
3116 fn model_info_carries_weights_ready_through_the_projection() {
3117 let mut schema = test_generate_schema(
3118 "mlx-community/car894-test-4bit",
3119 "car894-test-4bit",
3120 ModelSource::Mlx {
3121 hf_repo: "mlx-community/car894-test-4bit".into(),
3122 hf_weight_file: None,
3123 },
3124 );
3125
3126 schema.weights_ready = false;
3127 assert!(
3128 !ModelInfo::from(&schema).weights_ready,
3129 "a schema with no weights on disk must project weights_ready = false"
3130 );
3131
3132 schema.weights_ready = true;
3133 assert!(
3134 ModelInfo::from(&schema).weights_ready,
3135 "a schema with weights on disk must project weights_ready = true"
3136 );
3137 }
3138
3139 #[test]
3145 fn model_info_carries_downloads_weights_through_the_projection() {
3146 let mlx = test_generate_schema(
3147 "mlx-community/car894-test-4bit",
3148 "car894-test-4bit",
3149 ModelSource::Mlx {
3150 hf_repo: "mlx-community/car894-test-4bit".into(),
3151 hf_weight_file: None,
3152 },
3153 );
3154 assert!(
3155 ModelInfo::from(&mlx).downloads_weights,
3156 "an MLX entry downloads weights"
3157 );
3158
3159 for (label, source) in [
3162 ("windows speech", ModelSource::WindowsSpeech {}),
3163 (
3164 "apple foundation",
3165 ModelSource::AppleFoundationModels { use_case: None },
3166 ),
3167 ] {
3168 let schema = test_generate_schema("car894/os-model", "os-model", source);
3169 let info = ModelInfo::from(&schema);
3170 assert!(
3171 !info.downloads_weights,
3172 "{label} installs nothing, so the projection must say so"
3173 );
3174 assert!(
3175 info.is_local,
3176 "{label} is still local — which is exactly why is_local cannot stand in"
3177 );
3178 }
3179
3180 let external = test_generate_schema(
3185 "car894/external-model",
3186 "external-model",
3187 ModelSource::VllmMlx {
3188 endpoint: "http://localhost:8000".into(),
3189 model_name: "mlx-community/car894-test-4bit".into(),
3190 },
3191 );
3192 assert!(!external.is_local());
3193 assert!(external.is_remote());
3194 assert!(!external.requires_apple_silicon());
3195 let info = ModelInfo::from(&external);
3196 assert!(!info.is_local);
3197 assert!(
3198 !info.downloads_weights,
3199 "external vllm-mlx owns its weights, so CAR installs nothing"
3200 );
3201 }
3202
3203 #[test]
3204 fn model_info_classifies_only_raw_vllm_mlx_as_operator_managed_external() {
3205 for endpoint in ["http://localhost:8000", "https://models.example.invalid/v1"] {
3206 let schema = test_generate_schema(
3207 "external/model",
3208 "external-model",
3209 ModelSource::VllmMlx {
3210 endpoint: endpoint.into(),
3211 model_name: "mlx-community/external-model".into(),
3212 },
3213 );
3214 let info = ModelInfo::from(&schema);
3215 assert!(info.operator_managed_external_runtime);
3216 assert_eq!(
3217 serde_json::to_value(info).unwrap()["operator_managed_external_runtime"],
3218 true
3219 );
3220 }
3221
3222 for source in [
3223 ModelSource::RemoteApi {
3224 endpoint: "https://cloud.example.invalid/v1".into(),
3225 api_key_env: "CAR_TEST_KEY".into(),
3226 api_key_envs: vec![],
3227 api_version: None,
3228 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3229 },
3230 ModelSource::ManagedVllmMlx {
3231 hf_repo: "mlx-community/car-owned-model".into(),
3232 hf_weight_file: None,
3233 },
3234 ] {
3235 assert!(
3236 !ModelInfo::from(&test_generate_schema(
3237 "not-external/model",
3238 "not-external-model",
3239 source,
3240 ))
3241 .operator_managed_external_runtime
3242 );
3243 }
3244 }
3245
3246 #[test]
3253 fn fresh_machine_mlx_entry_is_available_but_not_weights_ready() {
3254 let mut reg = test_registry();
3255 let id = "mlx-community/car894-fresh-4bit";
3256 reg.register(test_generate_schema(
3257 id,
3258 "car894-fresh-4bit",
3259 ModelSource::Mlx {
3260 hf_repo: "mlx-community/car894-fresh-4bit".into(),
3261 hf_weight_file: None,
3262 },
3263 ));
3264
3265 let registered = reg
3266 .get(id)
3267 .expect("the model just registered must be in the registry");
3268 let info = ModelInfo::from(registered);
3269
3270 assert!(
3272 !registered.weights_ready,
3273 "an empty models dir means no weights on disk"
3274 );
3275 assert!(
3276 !info.weights_ready,
3277 "the CLI-facing projection must report the same: nothing installed"
3278 );
3279
3280 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3281 {
3282 assert!(
3285 registered.available,
3286 "a declared hf_repo makes an MLX entry runnable before download (#164)"
3287 );
3288 assert!(
3289 info.available,
3290 "the projection must keep reporting it as runnable"
3291 );
3292 }
3293 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3294 {
3295 assert!(
3298 !registered.available,
3299 "MLX cannot execute on this target, so it must not be runnable"
3300 );
3301 assert!(!info.available);
3302 }
3303 }
3304
3305 fn write_complete_mlx_snapshot(dir: &Path) {
3306 std::fs::create_dir_all(dir).unwrap();
3307 std::fs::write(dir.join("config.json"), b"{}").unwrap();
3308 std::fs::write(dir.join("tokenizer.json"), b"{}").unwrap();
3309 std::fs::write(
3310 dir.join("model.safetensors.index.json"),
3311 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
3312 )
3313 .unwrap();
3314 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"one").unwrap();
3315 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
3316 }
3317
3318 #[test]
3319 fn complete_managed_and_shared_mlx_snapshots_are_physically_ready() {
3320 let schema = test_generate_schema(
3321 "mlx/qwen3-4b:4bit",
3322 "Qwen3-4B-MLX",
3323 ModelSource::Mlx {
3324 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
3325 hf_weight_file: None,
3326 },
3327 );
3328 let root = tempfile::tempdir().unwrap();
3329 let managed = root.path().join("managed");
3330 let shared = root.path().join("shared");
3331
3332 write_complete_mlx_snapshot(&managed);
3333 assert!(mlx_weights_ready_at(&schema, &managed, None));
3334
3335 std::fs::remove_dir_all(&managed).unwrap();
3336 write_complete_mlx_snapshot(&shared);
3337 assert!(mlx_weights_ready_at(&schema, &managed, Some(&shared)));
3338 }
3339
3340 #[test]
3341 fn zero_byte_gguf_is_not_physically_ready() {
3342 let schema = test_generate_schema(
3343 "qwen/qwen3-4b:q4_k_m",
3344 "Qwen3-4B",
3345 ModelSource::Local {
3346 hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
3347 hf_filename: "model.gguf".into(),
3348 tokenizer_repo: "Qwen/Qwen3-4B".into(),
3349 },
3350 );
3351 let root = tempfile::tempdir().unwrap();
3352 let model_dir = root.path().join(&schema.name);
3353 std::fs::create_dir_all(&model_dir).unwrap();
3354 std::fs::write(model_dir.join("model.gguf"), b"").unwrap();
3355
3356 assert!(!physical_weights_ready(&schema, root.path()));
3357 std::fs::write(model_dir.join("model.gguf"), b"gguf").unwrap();
3358 assert!(physical_weights_ready(&schema, root.path()));
3359 }
3360
3361 #[test]
3362 fn shared_mlx_snapshot_missing_an_indexed_shard_is_not_physically_ready() {
3363 let schema = test_generate_schema(
3364 "mlx/qwen3-8b:4bit",
3365 "Qwen3-8B-MLX",
3366 ModelSource::Mlx {
3367 hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
3368 hf_weight_file: None,
3369 },
3370 );
3371 let root = tempfile::tempdir().unwrap();
3372 let managed = root.path().join("managed");
3373 let shared = root.path().join("shared");
3374 write_complete_mlx_snapshot(&shared);
3375 std::fs::remove_file(shared.join("model-00001-of-00002.safetensors")).unwrap();
3376
3377 assert!(!mlx_weights_ready_at(&schema, &managed, Some(&shared)));
3378 }
3379
3380 #[test]
3412 fn a_gateway_that_reports_no_upstream_stops_being_advertised() {
3413 let _guard = crate::openrouter::test_environment_scope();
3414 let _home = StateRootScope::new();
3417 crate::openrouter::clear_gateway_unconfigured();
3418
3419 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
3420 .into_iter()
3421 .filter(|s| crate::openrouter::is_curated_managed_gateway_alias(&s.id))
3422 .collect();
3423 assert!(
3424 !managed.is_empty(),
3425 "precondition: the curated catalog must still carry managed aliases"
3426 );
3427
3428 let availability_of = |schema: &ModelSchema| match &schema.source {
3429 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
3430 &schema.id,
3431 &schema.provider,
3432 provider,
3433 auth,
3434 true,
3436 &std::collections::HashMap::new(),
3437 ),
3438 other => panic!("managed aliases must be Proprietary, got {other:?}"),
3439 };
3440
3441 assert!(
3442 managed.iter().all(availability_of),
3443 "precondition: an authenticated session advertises these today"
3444 );
3445
3446 crate::openrouter::note_gateway_unconfigured();
3447 assert!(
3448 managed.iter().all(|s| !availability_of(s)),
3449 "after the gateway says it has no OpenRouter upstream, every alias in \
3450 the namespace must report unavailable — that claim is what cost the \
3451 benchmark sweep in #786"
3452 );
3453
3454 crate::openrouter::clear_gateway_unconfigured();
3458 assert!(
3459 managed.iter().all(availability_of),
3460 "the suppression must be recoverable, not a one-way latch"
3461 );
3462 }
3463
3464 #[test]
3478 fn constructing_a_registry_with_a_live_session_leaves_the_gateway_observation_alone() {
3479 let _guard = crate::openrouter::test_environment_scope();
3480 let home = StateRootScope::new();
3481
3482 crate::openrouter::note_gateway_unconfigured();
3483 let _registry = UnifiedRegistry::new_with_session(
3484 home.path().to_path_buf(),
3485 home.path().join("models"),
3486 None,
3487 SessionProbe::Fixed(true),
3488 );
3489
3490 let observed = crate::openrouter::gateway_unconfigured();
3491 let persisted = crate::openrouter::gateway_state_path().exists();
3492
3493 crate::openrouter::clear_gateway_unconfigured();
3494
3495 assert!(
3496 observed,
3497 "a signed-in session has no reason to forget what the gateway said"
3498 );
3499 assert!(
3500 persisted,
3501 "the durable half of the observation must survive construction too"
3502 );
3503 }
3504
3505 #[test]
3513 fn constructing_a_registry_with_no_session_still_forgets_the_gateway_observation() {
3514 let _guard = crate::openrouter::test_environment_scope();
3515 let home = StateRootScope::new();
3516
3517 crate::openrouter::note_gateway_unconfigured();
3518 let recorded = crate::openrouter::gateway_unconfigured();
3519 let _registry = UnifiedRegistry::new_with_session(
3520 home.path().to_path_buf(),
3521 home.path().join("models"),
3522 None,
3523 SessionProbe::Fixed(false),
3524 );
3525
3526 let observed = crate::openrouter::gateway_unconfigured();
3527 let persisted = crate::openrouter::gateway_state_path().exists();
3528
3529 crate::openrouter::clear_gateway_unconfigured();
3530
3531 assert!(
3532 recorded,
3533 "precondition: the observation is on record before construction"
3534 );
3535 assert!(
3536 !observed,
3537 "sign-out must still discard the session-scoped verdict (#786)"
3538 );
3539 assert!(
3540 !persisted,
3541 "and the durable copy with it — otherwise the next sign-in inherits it from disk"
3542 );
3543 }
3544
3545 #[test]
3562 fn an_ordinary_test_registry_does_not_disturb_a_separately_set_observation() {
3563 let _guard = crate::openrouter::test_environment_scope();
3564 let home = StateRootScope::new();
3565
3566 crate::openrouter::note_gateway_unconfigured();
3567 let _registry = UnifiedRegistry::new_with_state_root(
3568 home.path().to_path_buf(),
3569 home.path().join("models"),
3570 );
3571
3572 let observed = crate::openrouter::gateway_unconfigured();
3573
3574 crate::openrouter::clear_gateway_unconfigured();
3575
3576 assert!(
3577 observed,
3578 "constructing a registry is not a statement about the session, so it \
3579 must not erase an observation another test just recorded (#986)"
3580 );
3581 assert!(
3582 !SessionProbe::Inert.may_forget_session_evidence(),
3583 "the `cfg(test)` construction default must be a probe that answers \
3584 the session question without acting on it — this is the half of \
3585 the guarantee that does not depend on whether the runner happens \
3586 to be signed in"
3587 );
3588 }
3589
3590 #[test]
3603 fn a_rejected_credential_stops_the_managed_lane_being_advertised() {
3604 let _guard = crate::openrouter::test_environment_scope();
3605 let _home = StateRootScope::new();
3606 crate::parslee_credential::clear_credential_rejected();
3607
3608 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
3609 .into_iter()
3610 .filter(|s| s.provider == "parslee")
3611 .collect();
3612 assert!(
3613 !managed.is_empty(),
3614 "precondition: the curated catalog must still carry parslee rows"
3615 );
3616
3617 let availability_of = |schema: &ModelSchema| match &schema.source {
3618 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
3619 &schema.id,
3620 &schema.provider,
3621 provider,
3622 auth,
3623 true,
3625 &std::collections::HashMap::new(),
3626 ),
3627 other => panic!("parslee rows must be Proprietary, got {other:?}"),
3628 };
3629
3630 assert!(
3631 managed.iter().all(availability_of),
3632 "precondition: an authenticated session advertises these today"
3633 );
3634
3635 crate::parslee_credential::note_credential_rejected();
3636 assert!(
3637 managed.iter().all(|s| !availability_of(s)),
3638 "after the server rejects the credential, EVERY parslee row must \
3639 report unavailable — unlike the gateway verdict this is not scoped \
3640 to the curated OpenRouter aliases, because a dead credential kills \
3641 the whole namespace"
3642 );
3643
3644 crate::parslee_credential::clear_credential_rejected();
3647 assert!(
3648 managed.iter().all(availability_of),
3649 "the suppression must lift once the credential works again"
3650 );
3651 }
3652
3653 #[test]
3654 fn refresh_availability_probes_each_credential_once_not_per_model() {
3655 let _environment = crate::openrouter::test_environment_scope();
3668 let tmp = TempDir::new().unwrap();
3669 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
3670 for i in 0..25 {
3671 let mut schema = test_generate_schema(
3672 &format!("openrouter/model-{i}"),
3673 &format!("model-{i}"),
3674 ModelSource::RemoteApi {
3675 protocol: crate::schema::ApiProtocol::OpenRouter,
3676 endpoint: "https://openrouter.ai/api/v1".into(),
3677 api_key_env: "OPENROUTER_API_KEY".into(),
3678 api_key_envs: vec![],
3679 api_version: None,
3680 },
3681 );
3682 schema.provider = "openrouter".into();
3683 registry.register(schema);
3684 }
3685
3686 crate::openrouter::reset_credential_source_call_count();
3687 registry.refresh_availability();
3688 let calls = crate::openrouter::credential_source_call_count();
3689
3690 assert_eq!(
3691 calls, 1,
3692 "refresh_availability probed the OpenRouter credential {calls} times for 25 models; \
3693 it must resolve each distinct credential once per refresh, not once per model"
3694 );
3695 }
3696
3697 #[test]
3703 fn a_vllm_mlx_pull_targets_the_shared_huggingface_cache() {
3704 let repo = "mlx-community/Qwen3.8-27B-4bit";
3705 let dir = huggingface_repo_dir(repo);
3706 assert!(
3707 dir.ends_with("models--mlx-community--Qwen3.8-27B-4bit"),
3708 "got {}",
3709 dir.display()
3710 );
3711 assert!(
3712 dir.parent().is_some_and(|p| p.ends_with("hub")),
3713 "must live under the HF cache's hub/ root, got {}",
3714 dir.display()
3715 );
3716 }
3717
3718 #[test]
3721 fn external_vllm_mlx_does_not_become_managed_from_a_loopback_endpoint() {
3722 let _environment = crate::openrouter::test_environment_scope();
3735 let tmp = TempDir::new().unwrap();
3736 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
3737 let schema = test_generate_schema(
3738 "vllm-mlx/arch-the-rust-backend-cannot-load",
3739 "external-only-model",
3740 ModelSource::VllmMlx {
3741 endpoint: "http://localhost:8000".into(),
3742 model_name: "mlx-community/Qwen3.8-27B-4bit".into(),
3743 },
3744 );
3745 registry.register(schema);
3746
3747 assert!(
3749 std::env::var("VLLM_MLX_ENDPOINT").is_err(),
3750 "test precondition: VLLM_MLX_ENDPOINT must be unset"
3751 );
3752 registry.refresh_availability();
3753
3754 let model = registry
3755 .get("vllm-mlx/arch-the-rust-backend-cannot-load")
3756 .expect("registered model should be present");
3757 assert!(
3758 !model.available,
3759 "an external vllm-mlx row remains external even on loopback; only an \
3760 explicit ManagedVllmMlx source may use CAR's runtime"
3761 );
3762 }
3763
3764 #[test]
3765 fn user_config_load_and_save_force_community_trust() {
3766 let tmp = TempDir::new().unwrap();
3767 let models_dir = tmp.path().join("models");
3768 let config_path = tmp.path().join("models.json");
3769 let schema = test_generate_schema(
3770 "user/test-model",
3771 "user-test-model",
3772 ModelSource::RemoteApi {
3773 endpoint: "https://attacker.invalid/v1/chat/completions".into(),
3774 api_key_env: "CAR_USER_MODEL_TEST_KEY".into(),
3775 api_key_envs: vec![],
3776 api_version: None,
3777 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3778 },
3779 );
3780 let mut omitted_tier = serde_json::to_value(schema.clone()).unwrap();
3781 omitted_tier.as_object_mut().unwrap().remove("trust_tier");
3782 std::fs::write(
3783 &config_path,
3784 serde_json::to_vec_pretty(&vec![omitted_tier]).unwrap(),
3785 )
3786 .unwrap();
3787
3788 let mut loaded = UnifiedRegistry::new_empty(models_dir.clone());
3789 loaded.load_user_config().unwrap();
3790 assert_eq!(
3791 loaded.get("user/test-model").unwrap().trust_tier,
3792 crate::schema::TrustTier::Community
3793 );
3794
3795 let mut persisted = UnifiedRegistry::new_empty(models_dir);
3796 persisted.register_user_model(schema);
3797 persisted.save_user_config().unwrap();
3798 let saved: Vec<ModelSchema> =
3799 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
3800 assert_eq!(saved.len(), 1);
3801 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
3802 }
3803
3804 #[test]
3805 fn persisted_user_model_cannot_shadow_managed_openrouter_alias() {
3806 let _environment = crate::openrouter::test_environment_scope();
3811 let tmp = TempDir::new().unwrap();
3812 let models_dir = tmp.path().join("models");
3813 let config_path = tmp.path().join("models.json");
3814 let mut shadow = crate::openrouter::curated_schemas()
3815 .into_iter()
3816 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
3817 .unwrap();
3818 shadow.provider = "attacker".into();
3819 std::fs::write(
3820 &config_path,
3821 serde_json::to_vec_pretty(&vec![shadow]).unwrap(),
3822 )
3823 .unwrap();
3824
3825 let registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
3826 let actual = registry
3827 .get("parslee/openrouter/frontier-general")
3828 .expect("compiled managed alias must remain present");
3829 assert_eq!(actual.provider, "parslee");
3830 assert_eq!(
3831 crate::openrouter::canonical_managed_gateway_selector(actual),
3832 Some("parslee/openrouter/frontier-general")
3833 );
3834 }
3835
3836 #[test]
3837 fn user_config_persistence_excludes_signed_rows_and_keeps_builtin_tagged_user_rows() {
3838 let _environment = crate::openrouter::test_environment_scope();
3843 let tmp = TempDir::new().unwrap();
3844 let models_dir = tmp.path().join("models");
3845 let config_path = tmp.path().join("models.json");
3846
3847 let signed = test_generate_schema(
3848 "signed/catalog-only",
3849 "signed-catalog-only",
3850 ModelSource::RemoteApi {
3851 endpoint: "https://catalog.example/v1".into(),
3852 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
3853 api_key_envs: vec![],
3854 api_version: None,
3855 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3856 },
3857 );
3858 assert!(!signed.tags.iter().any(|tag| tag == "builtin"));
3859 let (verified, public_key) = crate::catalog::signed_test_catalog(
3860 crate::catalog::CatalogDoc {
3861 version: 81,
3862 models: vec![signed],
3863 },
3864 81,
3865 );
3866 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
3867
3868 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
3869 tmp.path().to_path_buf(),
3870 models_dir.clone(),
3871 Some(public_key.as_str()),
3872 );
3873 let mut user = test_generate_schema(
3874 "user/builtin-tagged",
3875 "user-builtin-tagged",
3876 ModelSource::RemoteApi {
3877 endpoint: "https://user.example/v1".into(),
3878 api_key_env: "USER_MODEL_TEST_KEY".into(),
3879 api_key_envs: vec![],
3880 api_version: None,
3881 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3882 },
3883 );
3884 user.tags.push("builtin".into());
3885 registry.register_user_model(user);
3886 registry.save_user_config().unwrap();
3887
3888 let saved: Vec<ModelSchema> =
3889 serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
3890 assert_eq!(
3891 saved
3892 .iter()
3893 .map(|model| model.id.as_str())
3894 .collect::<Vec<_>>(),
3895 vec!["user/builtin-tagged"],
3896 "models.json must contain only explicitly user-registered rows"
3897 );
3898 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
3899
3900 let mut restarted = UnifiedRegistry::new_with_catalog_public_key(
3901 tmp.path().to_path_buf(),
3902 models_dir,
3903 Some(public_key.as_str()),
3904 );
3905 assert_eq!(
3906 restarted.get("signed/catalog-only").unwrap().trust_tier,
3907 crate::schema::TrustTier::Curated,
3908 "user persistence must not demote an unrelated signed catalog row"
3909 );
3910 assert_eq!(
3911 restarted.get("user/builtin-tagged").unwrap().trust_tier,
3912 crate::schema::TrustTier::Community
3913 );
3914 let mut signed_shadow = restarted.get("signed/catalog-only").unwrap().clone();
3915 signed_shadow.name = "user-shadow-of-signed-row".into();
3916 restarted.register_user_model(signed_shadow);
3917 assert_eq!(
3918 restarted.get("signed/catalog-only").unwrap().name,
3919 "signed-catalog-only",
3920 "a user row must not shadow a signature-verified project exact id"
3921 );
3922 }
3923
3924 #[test]
3925 fn empty_user_config_save_clears_stale_rows() {
3926 let tmp = TempDir::new().unwrap();
3927 let models_dir = tmp.path().join("models");
3928 let config_path = tmp.path().join("models.json");
3929 let stale = test_generate_schema(
3930 "user/stale",
3931 "stale",
3932 ModelSource::RemoteApi {
3933 endpoint: "https://stale.example/v1".into(),
3934 api_key_env: "STALE_USER_MODEL_TEST_KEY".into(),
3935 api_key_envs: vec![],
3936 api_version: None,
3937 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3938 },
3939 );
3940 std::fs::write(
3941 &config_path,
3942 serde_json::to_vec_pretty(&vec![stale]).unwrap(),
3943 )
3944 .unwrap();
3945
3946 UnifiedRegistry::new_empty(models_dir)
3947 .save_user_config()
3948 .unwrap();
3949
3950 let saved: Vec<ModelSchema> =
3951 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
3952 assert!(
3953 saved.is_empty(),
3954 "saving an empty user set must overwrite stale models.json rows"
3955 );
3956 }
3957
3958 #[test]
3959 fn unregister_then_save_removes_the_user_row_from_disk() {
3960 let tmp = TempDir::new().unwrap();
3961 let models_dir = tmp.path().join("models");
3962 let config_path = tmp.path().join("models.json");
3963 let mut registry = UnifiedRegistry::new_empty(models_dir);
3964 registry.register_project_model(test_generate_schema(
3965 "signed/not-user-removable",
3966 "not-user-removable",
3967 ModelSource::RemoteApi {
3968 endpoint: "https://catalog.example/v1".into(),
3969 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
3970 api_key_envs: vec![],
3971 api_version: None,
3972 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3973 },
3974 ));
3975 assert!(
3976 registry
3977 .unregister_user_model("signed/not-user-removable")
3978 .is_none(),
3979 "the user boundary cannot unregister an untracked catalog row"
3980 );
3981 assert!(registry.get("signed/not-user-removable").is_some());
3982 registry.register_user_model(test_generate_schema(
3983 "user/removable",
3984 "removable",
3985 ModelSource::RemoteApi {
3986 endpoint: "https://user.example/v1".into(),
3987 api_key_env: "REMOVABLE_USER_MODEL_TEST_KEY".into(),
3988 api_key_envs: vec![],
3989 api_version: None,
3990 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3991 },
3992 ));
3993 registry.save_user_config().unwrap();
3994 assert!(registry.unregister_user_model("user/removable").is_some());
3995 registry.save_user_config().unwrap();
3996
3997 let saved: Vec<ModelSchema> =
3998 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
3999 assert!(saved.is_empty());
4000 }
4001
4002 #[test]
4014 fn a_registration_written_under_car_home_is_the_file_the_registry_reads() {
4015 let _environment = crate::openrouter::test_environment_scope();
4016 let prior = std::env::var_os(car_home::ENV_VAR);
4017
4018 let state_root = TempDir::new().unwrap();
4019 let weights = TempDir::new().unwrap();
4022 let models_dir = weights.path().join("models");
4023 std::fs::create_dir_all(&models_dir).unwrap();
4024
4025 unsafe { std::env::set_var(car_home::ENV_VAR, state_root.path()) };
4026
4027 let write_path = user_config_path().expect("CAR_HOME must resolve a models.json path");
4029 assert_eq!(write_path, state_root.path().join(USER_MODELS_FILE));
4030 let registered = test_generate_schema(
4031 "user/relocated-daemon-model",
4032 "relocated-daemon-model",
4033 ModelSource::RemoteApi {
4034 endpoint: "https://relocated.example/v1".into(),
4035 api_key_env: "RELOCATED_DAEMON_MODEL_TEST_KEY".into(),
4036 api_key_envs: vec![],
4037 api_version: None,
4038 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4039 },
4040 );
4041 std::fs::write(
4042 &write_path,
4043 serde_json::to_vec_pretty(&vec![registered]).unwrap(),
4044 )
4045 .unwrap();
4046
4047 let registry = UnifiedRegistry::new(models_dir.clone());
4049
4050 match prior {
4051 Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
4052 None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
4053 }
4054
4055 assert!(
4056 registry.get("user/relocated-daemon-model").is_some(),
4057 "the registry must load the models.json that `models.register` wrote; \
4058 it looked at {} instead",
4059 registry.user_config_path.display(),
4060 );
4061 assert_eq!(registry.user_config_path, write_path);
4062 assert!(
4063 !weights.path().join(USER_MODELS_FILE).exists(),
4064 "nothing may be written beside the shared weights cache",
4065 );
4066 }
4067
4068 #[test]
4069 fn ready_without_download_is_strict_for_local_model_files() {
4070 let tmp = TempDir::new().unwrap();
4071 let models = tmp.path().join("models");
4072 let mut reg = UnifiedRegistry::new_empty(models.clone());
4073 reg.register(test_generate_schema(
4074 "local/test",
4075 "TestLocal",
4076 ModelSource::Local {
4077 hf_repo: "example/repo".into(),
4078 hf_filename: "model.gguf".into(),
4079 tokenizer_repo: "example/repo".into(),
4080 },
4081 ));
4082
4083 assert_eq!(reg.ready_without_download("local/test"), Some(false));
4084
4085 let dir = models.join("TestLocal");
4086 std::fs::create_dir_all(&dir).unwrap();
4087 std::fs::write(dir.join("model.gguf"), b"weights").unwrap();
4088 assert_eq!(
4089 reg.ready_without_download("local/test"),
4090 Some(false),
4091 "tokenizer is required too"
4092 );
4093 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
4094 assert_eq!(reg.ready_without_download("local/test"), Some(true));
4095 }
4096
4097 #[test]
4098 fn ready_without_download_rejects_mlx_config_only_stub() {
4099 let tmp = TempDir::new().unwrap();
4100 let models = tmp.path().join("models");
4101 let mut reg = UnifiedRegistry::new_empty(models.clone());
4102 reg.register(test_generate_schema(
4103 "mlx/test",
4104 "TestMlx",
4105 ModelSource::Mlx {
4106 hf_repo: "example/repo".into(),
4107 hf_weight_file: None,
4108 },
4109 ));
4110
4111 let dir = models.join("TestMlx");
4112 std::fs::create_dir_all(&dir).unwrap();
4113 std::fs::write(dir.join("config.json"), "{}").unwrap();
4114 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
4115 assert_eq!(
4116 reg.ready_without_download("mlx/test"),
4117 Some(false),
4118 "config/tokenizer stubs must not start assistant inference"
4119 );
4120
4121 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
4122 assert_eq!(reg.ready_without_download("mlx/test"), Some(true));
4123 }
4124
4125 fn write_mlx_dir(root: &Path, name: &str, model_type: &str) {
4126 let dir = root.join(name);
4127 std::fs::create_dir_all(&dir).unwrap();
4128 std::fs::write(
4129 dir.join("config.json"),
4130 serde_json::json!({
4131 "model_type": model_type,
4132 "max_position_embeddings": 40_960,
4133 "quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" },
4134 })
4135 .to_string(),
4136 )
4137 .unwrap();
4138 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
4139 }
4140
4141 struct ScopedEnvVar {
4142 name: &'static str,
4143 previous: Option<std::ffi::OsString>,
4144 }
4145
4146 impl ScopedEnvVar {
4147 fn set(name: &'static str, value: &Path) -> Self {
4148 let previous = std::env::var_os(name);
4149 unsafe { std::env::set_var(name, value) };
4152 Self { name, previous }
4153 }
4154 }
4155
4156 impl Drop for ScopedEnvVar {
4157 fn drop(&mut self) {
4158 unsafe {
4161 match self.previous.take() {
4162 Some(value) => std::env::set_var(self.name, value),
4163 None => std::env::remove_var(self.name),
4164 }
4165 }
4166 }
4167 }
4168
4169 #[derive(Default)]
4170 struct AcquisitionRecorder {
4171 events: std::sync::Mutex<Vec<DownloadEvent>>,
4172 replace_dir_on_started: std::sync::Mutex<Option<PathBuf>>,
4173 }
4174
4175 impl AcquisitionRecorder {
4176 fn replacing_dir_on_started(path: PathBuf) -> Self {
4177 Self {
4178 events: std::sync::Mutex::new(Vec::new()),
4179 replace_dir_on_started: std::sync::Mutex::new(Some(path)),
4180 }
4181 }
4182
4183 fn events(&self) -> Vec<DownloadEvent> {
4184 self.events.lock().unwrap().clone()
4185 }
4186 }
4187
4188 impl crate::download::DownloadProgress for AcquisitionRecorder {
4189 fn on_event(&self, event: &DownloadEvent) {
4190 self.events.lock().unwrap().push(event.clone());
4191 if matches!(event, DownloadEvent::Started { .. }) {
4192 if let Some(path) = self.replace_dir_on_started.lock().unwrap().take() {
4193 std::fs::remove_dir_all(&path).unwrap();
4194 std::fs::write(path, b"make create_dir_all fail before any network access")
4195 .unwrap();
4196 }
4197 }
4198 }
4199 }
4200
4201 fn started_count(events: &[DownloadEvent]) -> usize {
4202 events
4203 .iter()
4204 .filter(|event| matches!(event, DownloadEvent::Started { .. }))
4205 .count()
4206 }
4207
4208 fn mlx_schema(id: &str, name: &str, hf_repo: &str) -> ModelSchema {
4209 test_generate_schema(
4210 id,
4211 name,
4212 ModelSource::Mlx {
4213 hf_repo: hf_repo.into(),
4214 hf_weight_file: None,
4215 },
4216 )
4217 }
4218
4219 #[test]
4220 fn installing_a_fetched_file_replaces_only_unusable_destinations() {
4221 let tmp = TempDir::new().unwrap();
4222 let src = tmp.path().join("fetched");
4223 std::fs::write(&src, b"fetched bytes").unwrap();
4224
4225 let missing = tmp.path().join("missing");
4226 install_fetched_file(&src, &missing).unwrap();
4227 assert_eq!(std::fs::read(&missing).unwrap(), b"fetched bytes");
4228
4229 let usable = tmp.path().join("usable");
4230 std::fs::write(&usable, b"keep these bytes").unwrap();
4231 let usable_before = std::fs::symlink_metadata(&usable).unwrap();
4232 let modified_before = usable_before.modified().unwrap();
4233 #[cfg(unix)]
4234 let inode_before = {
4235 use std::os::unix::fs::MetadataExt;
4236 usable_before.ino()
4237 };
4238 install_fetched_file(&src, &usable).unwrap();
4239 let usable_after = std::fs::symlink_metadata(&usable).unwrap();
4240 assert_eq!(std::fs::read(&usable).unwrap(), b"keep these bytes");
4241 assert_eq!(usable_after.modified().unwrap(), modified_before);
4242 #[cfg(unix)]
4243 {
4244 use std::os::unix::fs::MetadataExt;
4245 assert_eq!(usable_after.ino(), inode_before);
4246 }
4247
4248 let zero_byte = tmp.path().join("zero-byte");
4249 std::fs::write(&zero_byte, b"").unwrap();
4250 install_fetched_file(&src, &zero_byte).unwrap();
4251 assert_eq!(std::fs::read(&zero_byte).unwrap(), b"fetched bytes");
4252
4253 #[cfg(unix)]
4254 {
4255 let dangling = tmp.path().join("dangling");
4256 std::os::unix::fs::symlink(tmp.path().join("absent"), &dangling).unwrap();
4257 assert!(std::fs::symlink_metadata(&dangling).unwrap().is_symlink());
4258 install_fetched_file(&src, &dangling).unwrap();
4259 assert_eq!(std::fs::read(&dangling).unwrap(), b"fetched bytes");
4260 }
4261 }
4262
4263 #[cfg(unix)]
4266 #[tokio::test]
4267 async fn a_zero_byte_flux_auxiliary_file_is_not_accepted_as_present() {
4268 let _environment = crate::openrouter::test_environment_scope_async().await;
4269 let tmp = TempDir::new().unwrap();
4270 let home = tmp.path().join("home");
4271 let _home = ScopedEnvVar::set("HOME", &home);
4272 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
4273
4274 let cached_auxiliary = home
4277 .join(".cache/huggingface/hub")
4278 .join("models--Freepik--flux.1-lite-8B")
4279 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
4280 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
4281 std::fs::write(&cached_auxiliary, b"repaired tokenizer").unwrap();
4282 let refs = home
4283 .join(".cache/huggingface/hub")
4284 .join("models--Freepik--flux.1-lite-8B/refs");
4285 std::fs::create_dir_all(&refs).unwrap();
4286 std::fs::write(refs.join("main"), b"fixture").unwrap();
4287
4288 let models = tmp.path().join("models");
4289 let name = "Flux-1.lite-8B-MLX-Q4";
4290 write_mlx_dir(&models, name, "flux");
4291 let auxiliary = models.join(name).join("tokenizer_2/tokenizer.json");
4292 std::fs::create_dir_all(auxiliary.parent().unwrap()).unwrap();
4293 std::fs::write(&auxiliary, b"").unwrap();
4294 let mut reg = UnifiedRegistry::new_empty(models.clone());
4295 reg.register(mlx_schema(
4296 "mlx/flux-zero-byte-aux",
4297 name,
4298 "mlx-community/Flux-1.lite-8B-MLX-Q4",
4299 ));
4300 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
4301 let sink = ProgressSink::new(recorder.clone());
4302
4303 let result = reg
4304 .acquire_and_ensure("mlx/flux-zero-byte-aux", &sink, false, None)
4305 .await;
4306 let events = recorder.events();
4307 assert!(matches!(
4308 events.first(),
4309 Some(DownloadEvent::Started { .. })
4310 ));
4311 assert_eq!(started_count(&events), 1);
4312 assert!(
4313 result.is_err() || crate::download::cache_file_usable(&auxiliary),
4314 "acquisition must replace the zero-byte auxiliary file or report failure"
4315 );
4316 if result.is_ok() {
4317 assert_eq!(std::fs::read(auxiliary).unwrap(), b"repaired tokenizer");
4318 }
4319 }
4320
4321 #[cfg(unix)]
4324 #[tokio::test]
4325 async fn managed_flux_missing_auxiliary_does_not_fall_through_to_hf_snapshot() {
4326 let _environment = crate::openrouter::test_environment_scope_async().await;
4327 let tmp = TempDir::new().unwrap();
4328 let home = tmp.path().join("home");
4329 let hf_home = tmp.path().join("hf-home");
4330 let _home = ScopedEnvVar::set("HOME", &home);
4331 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
4332
4333 let cached_auxiliary = home
4336 .join(".cache/huggingface/hub")
4337 .join("models--Freepik--flux.1-lite-8B")
4338 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
4339 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
4340 std::fs::write(&cached_auxiliary, b"managed repair tokenizer").unwrap();
4341 let refs = home
4342 .join(".cache/huggingface/hub")
4343 .join("models--Freepik--flux.1-lite-8B/refs");
4344 std::fs::create_dir_all(&refs).unwrap();
4345 std::fs::write(refs.join("main"), b"fixture").unwrap();
4346
4347 let models = tmp.path().join("models");
4348 let name = "Flux-1.lite-8B-MLX-Q4";
4349 let managed = models.join(name);
4350 write_mlx_dir(&models, name, "flux");
4351 let managed_auxiliary = managed.join("tokenizer_2/tokenizer.json");
4352
4353 let snapshot = hf_home
4356 .join("hub/models--mlx-community--Flux-1.lite-8B-MLX-Q4")
4357 .join("snapshots/fixture");
4358 write_complete_mlx_snapshot(&snapshot);
4359 let snapshot_auxiliary = snapshot.join("tokenizer_2/tokenizer.json");
4360 std::fs::create_dir_all(snapshot_auxiliary.parent().unwrap()).unwrap();
4361 std::fs::write(&snapshot_auxiliary, b"snapshot tokenizer").unwrap();
4362
4363 let id = "mlx/flux-managed-precedence";
4364 let mut reg = UnifiedRegistry::new_empty(models);
4365 reg.register(mlx_schema(id, name, "mlx-community/Flux-1.lite-8B-MLX-Q4"));
4366 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
4367 let sink = ProgressSink::new(recorder.clone());
4368
4369 let path = reg
4370 .acquire_and_ensure(id, &sink, false, None)
4371 .await
4372 .unwrap();
4373
4374 assert_eq!(path, managed);
4375 assert_eq!(
4376 std::fs::read(managed_auxiliary).unwrap(),
4377 b"managed repair tokenizer"
4378 );
4379 let events = recorder.events();
4380 assert!(matches!(
4381 events.first(),
4382 Some(DownloadEvent::Started { .. })
4383 ));
4384 assert_eq!(started_count(&events), 1);
4385 }
4386
4387 #[tokio::test]
4388 async fn reusing_a_complete_managed_mlx_dir_emits_no_acquisition_lifecycle() {
4389 let _environment = crate::openrouter::test_environment_scope_async().await;
4390 let tmp = TempDir::new().unwrap();
4391 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
4392 let models = tmp.path().join("models");
4393 let mut reg = UnifiedRegistry::new_empty(models.clone());
4394 reg.register(mlx_schema(
4395 "mlx/reuse-complete",
4396 "Reuse-Complete-MLX",
4397 "example/reuse-complete",
4398 ));
4399 write_mlx_dir(&models, "Reuse-Complete-MLX", "qwen3");
4400 std::fs::write(
4401 models.join("Reuse-Complete-MLX").join("tokenizer.json"),
4402 b"{}",
4403 )
4404 .unwrap();
4405 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
4406 let sink = ProgressSink::new(recorder.clone());
4407
4408 let path = reg
4409 .ensure_local_with_progress("mlx/reuse-complete", &sink)
4410 .await
4411 .unwrap();
4412
4413 assert_eq!(path, models.join("Reuse-Complete-MLX"));
4414 assert!(recorder.events().is_empty());
4415 }
4416
4417 #[tokio::test]
4418 async fn reusing_a_managed_mlx_dir_without_tokenizer_json_emits_no_lifecycle() {
4419 let _environment = crate::openrouter::test_environment_scope_async().await;
4420 let tmp = TempDir::new().unwrap();
4421 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
4422 let models = tmp.path().join("models");
4423 let mut reg = UnifiedRegistry::new_empty(models.clone());
4424 reg.register(mlx_schema(
4425 "mlx/reuse-no-tokenizer",
4426 "Reuse-No-Tokenizer-MLX",
4427 "example/reuse-no-tokenizer",
4428 ));
4429 write_mlx_dir(&models, "Reuse-No-Tokenizer-MLX", "qwen3");
4430 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
4431 let sink = ProgressSink::new(recorder.clone());
4432
4433 let path = reg
4434 .ensure_local_with_progress("mlx/reuse-no-tokenizer", &sink)
4435 .await
4436 .unwrap();
4437
4438 assert_eq!(path, models.join("Reuse-No-Tokenizer-MLX"));
4439 assert!(recorder.events().is_empty());
4440 }
4441
4442 #[tokio::test]
4443 async fn reusing_a_complete_hf_snapshot_emits_no_acquisition_lifecycle() {
4444 let _environment = crate::openrouter::test_environment_scope_async().await;
4445 let tmp = TempDir::new().unwrap();
4446 let hf_home = tmp.path().join("hf-home");
4447 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
4448 let models = tmp.path().join("models");
4449 let repo = "example/reuse-hf-snapshot";
4450 let snapshot = hf_home
4451 .join("hub")
4452 .join("models--example--reuse-hf-snapshot")
4453 .join("snapshots")
4454 .join("fixture");
4455 write_complete_mlx_snapshot(&snapshot);
4456 let mut reg = UnifiedRegistry::new_empty(models);
4457 reg.register(mlx_schema("mlx/reuse-hf", "Reuse-HF-MLX", repo));
4458 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
4459 let sink = ProgressSink::new(recorder.clone());
4460
4461 let path = reg
4462 .ensure_local_with_progress("mlx/reuse-hf", &sink)
4463 .await
4464 .unwrap();
4465
4466 assert_eq!(path, snapshot);
4467 assert!(recorder.events().is_empty());
4468 }
4469
4470 #[cfg(unix)]
4471 #[tokio::test]
4472 async fn an_oversized_installed_model_is_reused_below_the_disk_threshold() {
4473 let _environment = crate::openrouter::test_environment_scope_async().await;
4474 let tmp = TempDir::new().unwrap();
4475 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
4476 let models = tmp.path().join("models");
4477 let mut schema = mlx_schema(
4478 "mlx/reuse-oversized",
4479 "Reuse-Oversized-MLX",
4480 "example/reuse-oversized",
4481 );
4482 schema.cost.size_mb = Some(1_000_000_000);
4485 let mut reg = UnifiedRegistry::new_empty(models.clone());
4486 reg.register(schema);
4487 write_mlx_dir(&models, "Reuse-Oversized-MLX", "qwen3");
4488 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
4489 let sink = ProgressSink::new(recorder.clone());
4490
4491 let path = reg
4492 .ensure_local_with_progress("mlx/reuse-oversized", &sink)
4493 .await
4494 .unwrap();
4495
4496 assert_eq!(path, models.join("Reuse-Oversized-MLX"));
4497 assert!(recorder.events().is_empty());
4498 }
4499
4500 #[tokio::test]
4501 async fn a_missing_shard_starts_acquisition_exactly_once_after_the_preflight() {
4502 let _environment = crate::openrouter::test_environment_scope_async().await;
4503 let tmp = TempDir::new().unwrap();
4504 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
4505 let models = tmp.path().join("models");
4506 let model_dir = models.join("Missing-Shard-MLX");
4507 std::fs::create_dir_all(&model_dir).unwrap();
4508 std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
4509 std::fs::write(
4510 model_dir.join("model.safetensors.index.json"),
4511 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
4512 )
4513 .unwrap();
4514 std::fs::write(model_dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
4515 let mut reg = UnifiedRegistry::new_empty(models);
4516 reg.register(mlx_schema(
4517 "mlx/missing-shard-lifecycle",
4518 "Missing-Shard-MLX",
4519 "example/missing-shard",
4520 ));
4521 let recorder =
4522 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
4523 let sink = ProgressSink::new(recorder.clone());
4524
4525 assert!(reg
4526 .ensure_local_with_progress("mlx/missing-shard-lifecycle", &sink)
4527 .await
4528 .is_err());
4529 let events = recorder.events();
4530 assert!(matches!(
4531 events.first(),
4532 Some(DownloadEvent::Started { .. })
4533 ));
4534 assert_eq!(started_count(&events), 1);
4535 }
4536
4537 #[tokio::test]
4538 async fn a_flux_dir_missing_its_auxiliary_tokenizer_keeps_the_lifecycle() {
4539 let _environment = crate::openrouter::test_environment_scope_async().await;
4540 let tmp = TempDir::new().unwrap();
4541 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
4542 let models = tmp.path().join("models");
4543 let name = "Flux-1.lite-8B-MLX-Q4";
4544 write_mlx_dir(&models, name, "flux");
4545 std::fs::write(models.join(name).join("tokenizer_2"), b"not a directory").unwrap();
4546 let mut reg = UnifiedRegistry::new_empty(models);
4547 reg.register(mlx_schema(
4548 "mlx/flux-missing-aux",
4549 name,
4550 "mlx-community/Flux-1.lite-8B-MLX-Q4",
4551 ));
4552 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
4553 let sink = ProgressSink::new(recorder.clone());
4554
4555 assert!(reg
4556 .ensure_local_with_progress("mlx/flux-missing-aux", &sink)
4557 .await
4558 .is_err());
4559 let events = recorder.events();
4560 assert!(matches!(
4561 events.first(),
4562 Some(DownloadEvent::Started { .. })
4563 ));
4564 assert_eq!(started_count(&events), 1);
4565 }
4566
4567 #[tokio::test]
4568 async fn force_bypasses_reuse() {
4569 let _environment = crate::openrouter::test_environment_scope_async().await;
4570 let tmp = TempDir::new().unwrap();
4571 let models = tmp.path().join("models");
4572 let name = "Force-Local";
4573 let model_dir = models.join(name);
4574 std::fs::create_dir_all(&model_dir).unwrap();
4575 std::fs::write(model_dir.join("model.gguf"), b"weights").unwrap();
4576 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
4577 let mut reg = UnifiedRegistry::new_empty(models);
4578 reg.register(test_generate_schema(
4579 "local/force-lifecycle",
4580 name,
4581 ModelSource::Local {
4582 hf_repo: String::new(),
4583 hf_filename: "model.gguf".into(),
4584 tokenizer_repo: String::new(),
4585 },
4586 ));
4587 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
4588 let sink = ProgressSink::new(recorder.clone());
4589
4590 assert!(reg
4591 .acquire_and_ensure("local/force-lifecycle", &sink, true, None)
4592 .await
4593 .is_err());
4594 let events = recorder.events();
4595 assert!(matches!(
4596 events.first(),
4597 Some(DownloadEvent::Started { .. })
4598 ));
4599 assert_eq!(started_count(&events), 1);
4600 }
4601
4602 #[tokio::test]
4603 async fn staged_pulls_keep_the_lifecycle() {
4604 let _environment = crate::openrouter::test_environment_scope_async().await;
4605 let tmp = TempDir::new().unwrap();
4606 let models = tmp.path().join("models");
4607 let canonical = models.join("Staged-Local");
4608 std::fs::create_dir_all(&canonical).unwrap();
4609 std::fs::write(canonical.join("model.gguf"), b"canonical weights").unwrap();
4610 std::fs::write(canonical.join("tokenizer.json"), b"{}").unwrap();
4611 let mut reg = UnifiedRegistry::new_empty(models);
4612 reg.register(test_generate_schema(
4613 "local/staged-lifecycle",
4614 "Staged-Local",
4615 ModelSource::Local {
4616 hf_repo: String::new(),
4617 hf_filename: "model.gguf".into(),
4618 tokenizer_repo: String::new(),
4619 },
4620 ));
4621 let staging = tmp.path().join("staging");
4622 std::fs::create_dir_all(&staging).unwrap();
4623 std::fs::write(staging.join("model.gguf"), b"weights").unwrap();
4624 std::fs::write(staging.join("tokenizer.json"), b"{}").unwrap();
4625 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
4626 let sink = ProgressSink::new(recorder.clone());
4627
4628 let path = reg
4629 .acquire_and_ensure("local/staged-lifecycle", &sink, false, Some(&staging))
4630 .await
4631 .unwrap();
4632 assert_eq!(path, staging);
4633 let events = recorder.events();
4634 assert!(matches!(
4635 events.first(),
4636 Some(DownloadEvent::Started { .. })
4637 ));
4638 assert_eq!(started_count(&events), 1);
4639 }
4640
4641 #[test]
4642 fn synthesize_local_schema_classifies_by_name_and_arch() {
4643 let tmp = TempDir::new().unwrap();
4644 let root = tmp.path();
4645
4646 write_mlx_dir(root, "MyCustom-Qwen3-7B", "qwen3");
4647 let gen = synthesize_local_schema("MyCustom-Qwen3-7B", &root.join("MyCustom-Qwen3-7B"))
4648 .expect("text LLM should be recognized");
4649 assert_eq!(
4650 gen.capabilities,
4651 vec![
4652 ModelCapability::Generate,
4653 ModelCapability::Code,
4654 ModelCapability::Reasoning
4655 ]
4656 );
4657 assert_eq!(gen.context_length, 40_960);
4658 assert_eq!(gen.provider, "local");
4659 assert!(matches!(gen.source, ModelSource::Mlx { .. }));
4660
4661 write_mlx_dir(root, "Some-Embedding-0.6B", "qwen3");
4662 let emb = synthesize_local_schema("Some-Embedding-0.6B", &root.join("Some-Embedding-0.6B"))
4663 .expect("embedding model recognized");
4664 assert_eq!(emb.capabilities, vec![ModelCapability::Embed]);
4665
4666 write_mlx_dir(root, "Mystery-Net", "some_unknown_arch");
4668 assert!(synthesize_local_schema("Mystery-Net", &root.join("Mystery-Net")).is_none());
4669
4670 write_mlx_dir(root, "silero-vad-v6-mlx", "qwen3");
4672 assert!(
4673 synthesize_local_schema("silero-vad-v6-mlx", &root.join("silero-vad-v6-mlx")).is_none()
4674 );
4675
4676 std::fs::create_dir_all(root.join("empty")).unwrap();
4678 assert!(synthesize_local_schema("empty", &root.join("empty")).is_none());
4679 }
4680
4681 #[test]
4687 fn a_scanned_gguf_directory_diagnoses_instead_of_downloading_from_nowhere() {
4688 let _environment = crate::openrouter::test_environment_scope();
4689 let tmp = TempDir::new().unwrap();
4690 let models = tmp.path().join("models");
4691 let dir = models.join("Dropped-In-Llama");
4692 std::fs::create_dir_all(&dir).unwrap();
4693 std::fs::write(dir.join("Llama-3-8B-Q4_K_M.gguf"), b"weights").unwrap();
4695
4696 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
4699 let err = tokio::runtime::Runtime::new()
4700 .unwrap()
4701 .block_on(reg.ensure_local("Dropped-In-Llama"))
4702 .expect_err("an unloadable layout must not report success");
4703 let err = err.to_string();
4704
4705 assert!(err.contains("model.gguf"), "must name what it reads: {err}");
4706 assert!(
4707 err.contains("tokenizer.json"),
4708 "must name the missing tokenizer too: {err}"
4709 );
4710 assert!(
4711 !err.contains("huggingface.co//"),
4712 "must not have tried to fetch from an empty repo: {err}"
4713 );
4714 }
4715
4716 #[test]
4717 fn discovery_registers_uncatalogued_local_model() {
4718 let _environment = crate::openrouter::test_environment_scope();
4723 let tmp = TempDir::new().unwrap();
4724 let models = tmp.path().join("models");
4725 std::fs::create_dir_all(&models).unwrap();
4726 write_mlx_dir(&models, "Totally-Custom-Llama-3B", "llama");
4727
4728 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
4729 let found = reg
4730 .list()
4731 .into_iter()
4732 .find(|m| m.name == "Totally-Custom-Llama-3B");
4733 assert!(
4734 found.is_some(),
4735 "uncatalogued on-disk model should be registered"
4736 );
4737 assert!(found.unwrap().tags.iter().any(|t| t == "auto-discovered"));
4738 }
4739
4740 #[test]
4741 fn explicit_user_row_is_not_pruned_by_auto_discovery_tag() {
4742 let _environment = crate::openrouter::test_environment_scope();
4743 let tmp = TempDir::new().unwrap();
4744 let models = tmp.path().join("models");
4745 let model_dir = models.join("Explicit-Custom-Llama");
4746 write_mlx_dir(&models, "Explicit-Custom-Llama", "llama");
4747
4748 let mut registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
4749 let model_id = "local/explicit-custom-llama";
4750 let schema = registry.get(model_id).cloned().unwrap();
4751 assert!(schema.tags.iter().any(|tag| tag == "auto-discovered"));
4752
4753 registry.register_user_model(schema);
4754 std::fs::remove_dir_all(model_dir).unwrap();
4755 registry.prune_missing_on_disk_models();
4756
4757 assert!(
4758 registry.get(model_id).is_some(),
4759 "explicit registration provenance must outrank a user-controlled tag"
4760 );
4761 }
4762
4763 #[test]
4764 fn signed_catalog_cannot_shadow_builtin_exact_id() {
4765 let _environment = crate::openrouter::test_environment_scope();
4770 let tmp = TempDir::new().unwrap();
4774 let models_dir = tmp.path().join("models");
4775
4776 let builtin = builtin_catalog();
4777 let mut overriding = builtin.first().expect("a built-in model").clone();
4778 let target_id = overriding.id.clone();
4779 overriding.name = "REPLACED-BY-CATALOG".into();
4780
4781 let (verified, public_key) = crate::catalog::signed_test_catalog(
4782 crate::catalog::CatalogDoc {
4783 version: 1,
4784 models: vec![overriding],
4785 },
4786 51,
4787 );
4788 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
4789
4790 let reg = UnifiedRegistry::new_with_catalog_public_key(
4792 tmp.path().to_path_buf(),
4793 models_dir,
4794 Some(public_key.as_str()),
4795 );
4796 assert_eq!(
4797 reg.get(&target_id).map(|m| m.name.as_str()),
4798 Some(builtin.first().unwrap().name.as_str()),
4799 "a signed cache row must not replace a builtin exact id"
4800 );
4801 }
4802
4803 #[test]
4804 fn user_model_cannot_shadow_project_owned_exact_id() {
4805 let mut reg = test_registry();
4806 let original = builtin_catalog().first().expect("a builtin").clone();
4807 let mut forged = original.clone();
4808 forged.name = "USER-SHADOW".into();
4809
4810 reg.register_user_model(forged);
4811
4812 assert_eq!(
4813 reg.get(&original.id).map(|model| model.name.as_str()),
4814 Some(original.name.as_str()),
4815 "a user row must not replace a project-owned exact id"
4816 );
4817 }
4818
4819 #[test]
4820 fn legacy_unsigned_catalog_cache_cannot_replace_builtin() {
4821 let _environment = crate::openrouter::test_environment_scope();
4826 let tmp = TempDir::new().unwrap();
4827 let models_dir = tmp.path().join("models");
4828 let builtin = builtin_catalog();
4829 let original = builtin.first().expect("a built-in model");
4830 let mut forged = original.clone();
4831 forged.name = "FORGED-UNSIGNED-CATALOG".into();
4832 let path = crate::catalog::cache_path(tmp.path());
4833 std::fs::write(
4834 &path,
4835 serde_json::to_vec_pretty(&crate::catalog::CatalogDoc {
4836 version: u64::MAX,
4837 models: vec![forged],
4838 })
4839 .unwrap(),
4840 )
4841 .unwrap();
4842
4843 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
4844 assert_eq!(
4845 reg.get(&original.id).map(|model| model.name.as_str()),
4846 Some(original.name.as_str()),
4847 "legacy unsigned cache JSON must fail closed and preserve the built-in"
4848 );
4849 }
4850
4851 #[test]
4852 fn tampered_signed_managed_row_preserves_builtin() {
4853 let _environment = crate::openrouter::test_environment_scope();
4858 let tmp = TempDir::new().unwrap();
4859 let models_dir = tmp.path().join("models");
4860 let original = builtin_catalog()
4861 .into_iter()
4862 .find(|model| model.id == "parslee/openrouter/frontier-general")
4863 .expect("managed frontier alias");
4864 let mut forged = original.clone();
4865 forged.name = "SIGNED-THEN-TAMPERED-MANAGED".into();
4866 let (verified, public_key) = crate::catalog::signed_test_catalog(
4867 crate::catalog::CatalogDoc {
4868 version: 9,
4869 models: vec![forged],
4870 },
4871 52,
4872 );
4873 let path = crate::catalog::cache_path(tmp.path());
4874 crate::catalog::save_verified(&path, &verified).unwrap();
4875 let cache = std::fs::read_to_string(&path)
4876 .unwrap()
4877 .replace("SIGNED-THEN-TAMPERED-MANAGED", "ATTACKER-MUTATION");
4878 std::fs::write(&path, cache).unwrap();
4879
4880 let reg = UnifiedRegistry::new_with_catalog_public_key(
4881 tmp.path().to_path_buf(),
4882 models_dir,
4883 Some(public_key.as_str()),
4884 );
4885 assert_eq!(
4886 reg.get(&original.id).map(|model| model.name.as_str()),
4887 Some(original.name.as_str()),
4888 "a tampered same-id managed row must fail verification and preserve the builtin"
4889 );
4890 }
4891
4892 #[test]
4893 fn builtin_catalog_loads() {
4894 let reg = test_registry();
4895 let all = reg.list();
4896 assert_eq!(all.len(), builtin_catalog().len());
4897 }
4898
4899 #[test]
4900 fn shipped_supervised_vllm_models_use_the_managed_source_contract() {
4901 let managed = builtin_catalog()
4902 .into_iter()
4903 .filter(|model| model.id.starts_with("vllm-mlx/"))
4904 .collect::<Vec<_>>();
4905 assert_eq!(managed.len(), 8);
4906 assert!(managed.iter().all(ModelSchema::is_car_managed_vllm_mlx));
4907 assert!(managed.iter().all(ModelSchema::downloads_weights));
4908 }
4909
4910 #[test]
4923 fn mlx_vlm_models_reflect_runtime_availability() {
4924 let reg = test_registry();
4925 let mlx_vlm_models: Vec<&ModelSchema> = reg
4926 .list()
4927 .into_iter()
4928 .filter(|m| m.tags.iter().any(|t| t == "requires-mlx-vlm"))
4929 .collect();
4930 assert!(
4931 !mlx_vlm_models.is_empty(),
4932 "catalog should contain at least one model tagged \
4933 `requires-mlx-vlm` — otherwise this regression has \
4934 nothing to guard"
4935 );
4936
4937 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4938 let expected = crate::backend::mlx_vlm_cli::is_available();
4939 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4940 let expected = false;
4941
4942 for m in mlx_vlm_models {
4943 assert_eq!(
4944 m.available, expected,
4945 "model {} `available` field should reflect \
4946 mlx_vlm CLI presence (expected {expected}, got {})",
4947 m.id, m.available
4948 );
4949 }
4950 }
4951
4952 #[test]
4964 fn mlx_models_unavailable_on_non_mlx_targets() {
4965 let reg = test_registry();
4966 let mlx_models: Vec<&ModelSchema> = reg
4967 .list()
4968 .into_iter()
4969 .filter(|m| {
4970 m.is_mlx()
4971 && !m.tags.iter().any(|t| t == "requires-mlx-vlm")
4976 && !m.tags.contains(&"speech".to_string())
4977 })
4978 .collect();
4979 assert!(
4980 !mlx_models.is_empty(),
4981 "catalog should contain at least one plain MLX model — \
4982 otherwise this F1 regression guard has nothing to guard"
4983 );
4984
4985 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4986 {
4987 let any_available = mlx_models.iter().any(|m| m.available);
4991 assert!(
4992 any_available,
4993 "on macOS arm64 with MLX enabled, at least one plain MLX \
4994 model with hf_repo should be available — none were"
4995 );
4996 }
4997 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4998 {
4999 for m in &mlx_models {
5002 assert!(
5003 !m.available,
5004 "MLX model {} is marked available on a non-MLX target — \
5005 the adaptive router will add it to fallback chains \
5006 and dispatch will fail (Parslee-ai/car#231 §7.1)",
5007 m.id
5008 );
5009 }
5010 }
5011 }
5012
5013 #[test]
5016 fn builtin_catalog_json_parses() {
5017 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON)
5018 .expect("builtin_catalog.json must be valid ModelSchema array");
5019 assert!(
5020 !catalog.is_empty(),
5021 "embedded catalog has no entries — that's almost certainly wrong"
5022 );
5023
5024 let mut seen = std::collections::HashSet::new();
5025 for entry in &catalog {
5026 assert!(
5027 seen.insert(entry.id.clone()),
5028 "duplicate id in builtin_catalog.json: {}",
5029 entry.id
5030 );
5031 }
5032 }
5033
5034 #[test]
5035 fn codex_subscription_row_has_pinnable_text_only_identity() {
5036 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
5037 let row = catalog
5038 .iter()
5039 .find(|model| model.id == "openai/gpt-5.6-sol:high")
5040 .expect("catalog publishes the Codex subscription identity");
5041 assert_eq!(row.name, "gpt-5.6-sol:high");
5042 assert!(matches!(
5043 &row.source,
5044 ModelSource::CodexCli { model } if model == "gpt-5.6-sol:high"
5045 ));
5046 assert!(row.has_capability(ModelCapability::Generate));
5047 assert!(row.has_capability(ModelCapability::Reasoning));
5048 assert!(!row.has_capability(ModelCapability::ToolUse));
5049 assert!(!row.has_capability(ModelCapability::MultiToolCall));
5050 assert!(!row.has_capability(ModelCapability::Vision));
5051 assert_eq!(row.supported_params, vec![GenerateParam::MaxTokens]);
5052 assert!(!row.downloads_weights());
5053 }
5054
5055 #[test]
5078 fn apple_foundation_row_claims_single_tool_call_and_the_real_context_window() {
5079 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
5080 let row = catalog
5081 .iter()
5082 .find(|model| model.id == "apple/foundation:default")
5083 .expect("catalog publishes the Apple FoundationModels row");
5084 assert!(matches!(
5085 &row.source,
5086 ModelSource::AppleFoundationModels { .. }
5087 ));
5088 assert!(row.has_capability(ModelCapability::Generate));
5089 assert!(
5090 row.has_capability(ModelCapability::ToolUse),
5091 "generate_with_tools exists, so the router must be able to pick this row for tool routes"
5092 );
5093 assert!(
5094 !row.has_capability(ModelCapability::MultiToolCall),
5095 "the capture sentinel ends the turn on the first tool call"
5096 );
5097 assert!(!row.has_capability(ModelCapability::Vision));
5098 assert_eq!(
5099 row.context_length, 4096,
5100 "SystemLanguageModel's context window is 4096 tokens"
5101 );
5102 assert!(!row.downloads_weights());
5103 }
5104
5105 #[test]
5118 fn apple_foundation_multi_tool_call_tracks_what_the_host_can_actually_do() {
5119 #[cfg(any(
5120 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
5121 all(target_os = "ios", target_arch = "aarch64")
5122 ))]
5123 {
5124 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
5125 let declared = catalog
5126 .iter()
5127 .find(|m| m.id == "apple/foundation:default")
5128 .expect("catalog publishes the Apple FoundationModels row");
5129 assert!(
5130 !declared.has_capability(ModelCapability::MultiToolCall),
5131 "the catalog literal must stay conservative; the host decides"
5132 );
5133
5134 let models_dir = tempfile::tempdir().unwrap();
5136 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
5137 let live = registry
5143 .models
5144 .get("apple/foundation:default")
5145 .expect("the builtin catalog row must survive into the live registry");
5146 let host_can = crate::backend::foundation_models::supports_parallel_tool_calls();
5147 assert_eq!(
5148 live.capabilities.contains(&ModelCapability::MultiToolCall),
5149 host_can,
5150 "live row must claim multi_tool_call iff the host supports it"
5151 );
5152 }
5153 }
5154
5155 #[test]
5167 fn apple_foundation_claims_vision_only_where_images_can_be_served() {
5168 #[cfg(any(
5169 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
5170 all(target_os = "ios", target_arch = "aarch64")
5171 ))]
5172 {
5173 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
5174 let declared = catalog
5175 .iter()
5176 .find(|m| m.id == "apple/foundation:default")
5177 .expect("catalog publishes the Apple FoundationModels row");
5178 assert!(
5179 !declared.has_capability(ModelCapability::Vision),
5180 "the catalog literal must stay conservative; the device decides"
5181 );
5182
5183 let models_dir = tempfile::tempdir().unwrap();
5184 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
5185 let live = registry
5186 .models
5187 .get("apple/foundation:default")
5188 .expect("the builtin catalog row must survive into the live registry");
5189
5190 assert_eq!(
5191 live.capabilities.contains(&ModelCapability::Vision),
5192 crate::backend::foundation_models::supports_vision(),
5193 "the live row must claim vision iff this device's model accepts images"
5194 );
5195 }
5196 }
5197
5198 #[test]
5215 fn a_host_without_parallel_support_gets_exactly_the_catalog_capabilities() {
5216 #[cfg(any(
5217 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
5218 all(target_os = "ios", target_arch = "aarch64")
5219 ))]
5220 {
5221 if crate::backend::foundation_models::supports_parallel_tool_calls() {
5222 return;
5223 }
5224 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
5225 let declared = catalog
5226 .iter()
5227 .find(|m| m.id == "apple/foundation:default")
5228 .expect("catalog publishes the Apple FoundationModels row");
5229
5230 let models_dir = tempfile::tempdir().unwrap();
5231 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
5232 let live = registry
5233 .models
5234 .get("apple/foundation:default")
5235 .expect("the builtin catalog row must survive into the live registry");
5236
5237 assert_eq!(
5238 live.capabilities, declared.capabilities,
5239 "a host without the macOS 27 behaviour must advertise exactly what \
5240 the catalog declares — nothing added, nothing removed"
5241 );
5242 }
5243 }
5244
5245 #[test]
5255 fn apple_foundation_context_size_is_either_the_os_answer_or_none() {
5256 #[cfg(any(
5257 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
5258 all(target_os = "ios", target_arch = "aarch64")
5259 ))]
5260 {
5261 if let Some(window) = crate::backend::foundation_models::context_size() {
5262 assert!(
5263 window >= 512,
5264 "a context window the framework reports must be usable, got {window}"
5265 );
5266 }
5267 }
5268
5269 #[cfg(not(any(
5272 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
5273 all(target_os = "ios", target_arch = "aarch64")
5274 )))]
5275 {
5276 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
5277 let row = catalog
5278 .iter()
5279 .find(|model| model.id == "apple/foundation:default")
5280 .expect("catalog publishes the Apple FoundationModels row");
5281 assert_eq!(row.context_length, 4096);
5282 }
5283 }
5284
5285 #[test]
5292 fn in_process_qwen3_models_declare_tool_use() {
5293 use crate::schema::ModelSource;
5294 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
5295 let tool_sizes = ["qwen3-4b", "qwen3-8b", "qwen3-30b-a3b"];
5298 let mut checked = 0;
5299 for entry in &catalog {
5300 let in_process = matches!(
5301 entry.source,
5302 ModelSource::Mlx { .. } | ModelSource::Local { .. }
5303 );
5304 if !in_process || !tool_sizes.iter().any(|s| entry.id.contains(s)) {
5305 continue;
5306 }
5307 assert!(
5308 entry.capabilities.contains(&ModelCapability::ToolUse),
5309 "in-process Qwen3 model {} should advertise ToolUse — the local \
5310 generate path renders/parses tool calls",
5311 entry.id
5312 );
5313 checked += 1;
5314 }
5315 assert_eq!(
5316 checked, 6,
5317 "expected 6 in-process tool-capable Qwen3 entries (3 mlx + 3 gguf)"
5318 );
5319 }
5320
5321 #[test]
5322 fn public_benchmarks_round_trip_through_model_info() {
5323 use crate::schema::BenchmarkScore;
5324 let mut reg = test_registry();
5325 let mut schema = reg
5326 .find_by_name("Qwen3-4B")
5327 .expect("catalog has Qwen3-4B")
5328 .clone();
5329 schema.id = "test/qwen3-4b-with-bench".into();
5330 schema.public_benchmarks = vec![
5331 BenchmarkScore {
5332 name: "MMLU-Pro".into(),
5333 score: 0.482,
5334 harness: Some("5-shot CoT".into()),
5335 source_url: Some("https://example.invalid/qwen3-4b-card".into()),
5336 measured_at: Some("2025-08-12".into()),
5337 },
5338 BenchmarkScore {
5339 name: "HumanEval".into(),
5340 score: 0.713,
5341 harness: Some("pass@1".into()),
5342 source_url: None,
5343 measured_at: None,
5344 },
5345 ];
5346 reg.register(schema);
5347
5348 let stored = reg
5349 .get("test/qwen3-4b-with-bench")
5350 .expect("registered model is retrievable");
5351 let info = ModelInfo::from(stored);
5352 assert_eq!(info.public_benchmarks.len(), 2);
5353
5354 let json = serde_json::to_string(&info).unwrap();
5356 assert!(json.contains("\"public_benchmarks\""));
5357 assert!(json.contains("\"MMLU-Pro\""));
5358 assert!(json.contains("\"5-shot CoT\""));
5359
5360 let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
5362 assert_eq!(decoded.public_benchmarks.len(), 2);
5363 assert_eq!(decoded.public_benchmarks[0].name, "MMLU-Pro");
5364 assert_eq!(decoded.public_benchmarks[1].name, "HumanEval");
5365 }
5366
5367 #[test]
5368 fn public_benchmarks_default_to_empty_when_absent_in_json() {
5369 let legacy_json = r#"{
5372 "id": "legacy/test:1",
5373 "name": "Legacy Test",
5374 "provider": "test",
5375 "family": "test",
5376 "version": "",
5377 "capabilities": ["generate"],
5378 "context_length": 4096,
5379 "param_count": "1B",
5380 "quantization": null,
5381 "performance": {},
5382 "cost": {},
5383 "source": { "type": "ollama", "model_tag": "legacy:1" },
5384 "tags": [],
5385 "supported_params": []
5386 }"#;
5387 let schema: ModelSchema = serde_json::from_str(legacy_json).unwrap();
5388 assert!(schema.public_benchmarks.is_empty());
5389 }
5390
5391 #[test]
5392 fn find_by_name() {
5393 let reg = test_registry();
5394 let m = reg.find_by_name("Qwen3-4B").unwrap();
5395 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
5396 assert_eq!(m.id, "mlx/qwen3-4b:4bit");
5397 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
5398 assert_eq!(m.id, "qwen/qwen3-4b:q4_k_m");
5399 assert!(m.has_capability(ModelCapability::Code));
5400 }
5401
5402 #[test]
5403 fn query_by_capability() {
5404 let reg = test_registry();
5405 let embed_models = reg.query_by_capability(ModelCapability::Embed);
5406 assert_eq!(embed_models.len(), 2);
5407 assert!(embed_models
5408 .iter()
5409 .any(|model| model.name == "Qwen3-Embedding-0.6B"));
5410 assert!(embed_models
5411 .iter()
5412 .any(|model| model.name == "Qwen3-Embedding-0.6B-MLX"));
5413 }
5414
5415 #[test]
5416 fn query_with_filter() {
5417 let reg = test_registry();
5418 let code_small = reg.query(&ModelFilter {
5419 capabilities: vec![ModelCapability::Code],
5420 max_size_mb: Some(3000),
5421 local_only: true,
5422 ..Default::default()
5423 });
5424 assert_eq!(code_small.len(), 4);
5426 }
5427
5428 #[test]
5429 fn register_remote() {
5430 let mut reg = test_registry();
5431 let initial_len = reg.list().len();
5432 let initial_reasoning_len = reg
5433 .query(&ModelFilter {
5434 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
5435 ..Default::default()
5436 })
5437 .len();
5438 let remote = ModelSchema {
5439 id: "anthropic/claude-sonnet-4-6:latest".into(),
5440 name: "Claude Sonnet 4.6".into(),
5441 provider: "anthropic".into(),
5442 family: "claude-4".into(),
5443 version: "latest".into(),
5444 capabilities: vec![
5445 ModelCapability::Generate,
5446 ModelCapability::Code,
5447 ModelCapability::Reasoning,
5448 ModelCapability::ToolUse,
5449 ],
5450 context_length: 200000,
5451 max_output_tokens: None,
5452 param_count: String::new(),
5453 quantization: None,
5454 performance: PerformanceEnvelope {
5455 latency_p50_ms: Some(2000),
5456 ..Default::default()
5457 },
5458 cost: CostModel {
5459 input_per_mtok: Some(3.0),
5460 output_per_mtok: Some(15.0),
5461 ..Default::default()
5462 },
5463 source: ModelSource::RemoteApi {
5464 endpoint: "https://api.anthropic.com/v1/messages".into(),
5465 api_key_env: "ANTHROPIC_API_KEY".into(),
5466 api_key_envs: vec![],
5467 api_version: Some("2023-06-01".into()),
5468 protocol: ApiProtocol::Anthropic,
5469 },
5470 tags: vec![],
5471 supported_params: vec![],
5472 public_benchmarks: vec![],
5473 trust_tier: crate::schema::TrustTier::Curated,
5474 deprecated: false,
5475 available: false,
5476 weights_ready: false,
5477 };
5478
5479 reg.register(remote);
5480 assert_eq!(reg.list().len(), initial_len);
5482
5483 let reasoning = reg.query(&ModelFilter {
5484 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
5485 ..Default::default()
5486 });
5487 assert_eq!(reasoning.len(), initial_reasoning_len);
5489 }
5490
5491 #[test]
5492 fn unregister() {
5493 let mut reg = test_registry();
5494 let initial_len = reg.list().len();
5495 let removed = reg.unregister("qwen/qwen3-0.6b:q8_0");
5496 assert!(removed.is_some());
5497 assert_eq!(reg.list().len(), initial_len - 1);
5498 }
5499
5500 #[test]
5501 fn speech_models_are_curated() {
5502 let reg = test_registry();
5503 let stt = reg.query_by_capability(ModelCapability::SpeechToText);
5504 let tts = reg.query_by_capability(ModelCapability::TextToSpeech);
5505 assert_eq!(stt.len(), 3);
5507 assert_eq!(tts.len(), 5);
5509 let whisper = stt
5512 .iter()
5513 .find(|m| m.name == "Whisper-large-v3-turbo-q5_0")
5514 .expect("whisper STT model should be curated");
5515 assert!(whisper.is_local());
5516 assert!(matches!(
5517 whisper.source,
5518 crate::schema::ModelSource::WhisperCpp { .. }
5519 ));
5520 }
5521
5522 #[test]
5523 fn qwen_8b_variants_keep_tool_use_consistent() {
5524 let reg = test_registry();
5529 for name in ["Qwen3-8B", "Qwen3-8B-MLX"] {
5530 let model = reg.find_by_name(name).expect("model should exist");
5531 assert!(model.has_capability(ModelCapability::ToolUse));
5532 assert!(model.has_capability(ModelCapability::MultiToolCall));
5533 }
5534 }
5535
5536 #[test]
5537 fn mac_name_resolution_prefers_mlx_siblings() {
5538 #[allow(unused_variables)]
5541 let reg = test_registry();
5542 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
5543 {
5544 assert_eq!(
5545 reg.find_by_name("Qwen3-0.6B").unwrap().id,
5546 "mlx/qwen3-0.6b:6bit"
5547 );
5548 assert_eq!(
5549 reg.find_by_name("Qwen3-1.7B").unwrap().id,
5550 "mlx/qwen3-1.7b:3bit"
5551 );
5552 assert_eq!(
5553 reg.find_by_name("Qwen3-Embedding-0.6B").unwrap().id,
5554 "mlx/qwen3-embedding-0.6b:mxfp8"
5555 );
5556 }
5557 }
5558
5559 #[test]
5560 fn remote_multimodal_models_are_curated_as_vision_capable() {
5561 let reg = test_registry();
5562 for name in [
5563 "claude-opus-4-7",
5564 "claude-opus-4-6",
5565 "claude-sonnet-4-6",
5566 "claude-haiku-4-5",
5567 "gpt-5.4",
5568 "gpt-5.4-mini",
5569 "o3",
5570 "o4-mini",
5571 "gpt-4.1-mini",
5572 "gemini-2.5-pro",
5573 "gemini-2.5-flash",
5574 ] {
5575 let model = reg.find_by_name(name).expect("model should exist");
5576 assert!(
5577 model.has_capability(ModelCapability::Vision),
5578 "{name} should be curated as vision-capable"
5579 );
5580 }
5581 }
5582
5583 #[test]
5584 fn qwen25vl_entries_are_replaced_by_qwen3vl_in_builtin_catalog() {
5585 let reg = test_registry();
5586
5587 let stale_ids = [
5588 "mlx/qwen2.5-vl-3b:4bit",
5590 "mlx/qwen2.5-vl-7b:4bit",
5591 "mlx-vlm/qwen2.5-vl-3b:4bit",
5594 "mlx-vlm/qwen2.5-vl-7b:4bit",
5595 "vllm-mlx/qwen2.5-vl-3b:4bit",
5597 ];
5598 for id in stale_ids {
5599 assert!(
5600 reg.get(id).is_none(),
5601 "{id} is superseded by Qwen3-VL; the catalog must not advertise it"
5602 );
5603 }
5604
5605 let vision_ids: Vec<&str> = reg
5606 .query_by_capability(ModelCapability::Vision)
5607 .into_iter()
5608 .map(|model| model.id.as_str())
5609 .collect();
5610 for stale in stale_ids {
5611 assert!(
5612 !vision_ids.contains(&stale),
5613 "{stale} must not be reachable through the Vision capability index"
5614 );
5615 }
5616 assert!(
5617 vision_ids.contains(&"mlx-vlm/qwen3-vl-2b:bf16"),
5618 "Qwen3-VL is the supported local VL family and must route as Vision"
5619 );
5620 }
5621
5622 #[test]
5623 fn gemini_models_are_curated_for_multimodal_tool_use() {
5624 let reg = test_registry();
5625 for name in ["gemini-2.5-pro", "gemini-2.5-flash"] {
5626 let model = reg.find_by_name(name).expect("model should exist");
5627 assert!(model.has_capability(ModelCapability::Vision));
5628 assert!(model.has_capability(ModelCapability::ToolUse));
5629 assert!(model.has_capability(ModelCapability::MultiToolCall));
5630 }
5631 }
5632
5633 #[test]
5634 fn model_info_publishes_declared_prices_and_keeps_unpriced_distinct_from_free() {
5635 let reg = test_registry();
5636
5637 let opus = reg
5639 .list()
5640 .into_iter()
5641 .find(|m| m.id == "openrouter/anthropic/claude-opus-4.8")
5642 .map(ModelInfo::from)
5643 .expect("curated opus-4.8 row is present on first boot");
5644 assert_eq!(opus.cost.input_per_mtok, Some(5.0));
5645 assert_eq!(opus.cost.output_per_mtok, Some(25.0));
5646 assert_eq!(opus.cost.cache_read_input_per_mtok, Some(0.5));
5647 assert_eq!(opus.cost.cache_write_input_per_mtok, Some(6.25));
5648
5649 let gpt = reg
5651 .list()
5652 .into_iter()
5653 .find(|m| m.id == "openrouter/openai/gpt-5.4")
5654 .map(ModelInfo::from)
5655 .expect("curated gpt-5.4 row");
5656 assert_eq!(gpt.cost.pricing_tiers.len(), 1);
5657 assert_eq!(gpt.cost.prices_for(272_000).input_per_mtok, Some(5.0));
5658
5659 let local = reg
5662 .list()
5663 .into_iter()
5664 .find(|m| m.is_local() && m.cost.input_per_mtok.is_none())
5665 .map(ModelInfo::from)
5666 .expect("the built-in catalog ships unpriced local models");
5667 let json = serde_json::to_value(&local).unwrap();
5668 assert!(json["cost"]["input_per_mtok"].is_null());
5669 assert!(json["cost"]["output_per_mtok"].is_null());
5670 assert_ne!(json["cost"]["input_per_mtok"], serde_json::json!(0.0));
5671 }
5672
5673 #[test]
5674 fn a_hand_registered_copy_of_a_curated_id_does_not_double_the_row() {
5675 let mut reg = test_registry();
5676 let id = "openrouter/anthropic/claude-opus-4.8";
5677 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
5678
5679 let mut copy = reg
5682 .list()
5683 .into_iter()
5684 .find(|m| m.id == id)
5685 .cloned()
5686 .expect("curated row");
5687 copy.name = "hand-registered".into();
5688 reg.register_user_model(copy);
5689 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
5690 }
5691
5692 #[test]
5700 fn managed_alias_publishes_prices_without_disclosing_the_upstream_id_in_the_catalog_view() {
5701 let reg = test_registry();
5702 let alias = reg
5703 .list()
5704 .into_iter()
5705 .find(|m| m.id == "parslee/openrouter/frontier-deep-next")
5706 .map(ModelInfo::from)
5707 .expect("managed alias for the new curated row");
5708
5709 assert_eq!(alias.cost.input_per_mtok, Some(5.0));
5710 assert_eq!(alias.cost.output_per_mtok, Some(25.0));
5711 assert_eq!(alias.cost.cache_read_input_per_mtok, Some(0.5));
5712 assert_eq!(alias.cost.cache_write_input_per_mtok, Some(6.25));
5713
5714 let wire = serde_json::to_string(&alias).unwrap();
5715 assert!(!wire.contains("claude-opus-4.8"));
5716 assert!(!wire.contains("anthropic/"));
5717 }
5718
5719 #[test]
5720 fn model_info_from_an_older_daemon_without_cost_still_parses() {
5721 let legacy = serde_json::json!({
5724 "id": "legacy/model",
5725 "name": "legacy",
5726 "provider": "legacy",
5727 "capabilities": ["generate"],
5728 "param_count": "",
5729 "size_mb": 0,
5730 "context_length": 8192,
5731 "available": true,
5732 "is_local": false
5733 });
5734 let info: ModelInfo = serde_json::from_value(legacy).expect("older catalog row parses");
5735 assert!(info.cost.input_per_mtok.is_none());
5736 assert!(info.cost.output_per_mtok.is_none());
5737 assert!(info.cost.pricing_tiers.is_empty());
5738 assert!(info.max_output_tokens.is_none());
5739 assert!(info.car_enabled, "legacy rows default to enabled");
5740 assert!(!info.can_remove);
5741 assert!(!info.in_use);
5742 assert!(info.management_evidence.is_none());
5743 }
5744
5745 #[test]
5746 fn visual_generation_models_are_curated() {
5747 let reg = test_registry();
5748 assert_eq!(
5749 reg.query_by_capability(ModelCapability::ImageGeneration)
5750 .len(),
5751 1
5752 );
5753 assert_eq!(
5754 reg.query_by_capability(ModelCapability::VideoGeneration)
5755 .len(),
5756 1
5757 );
5758 }
5759}
5760
5761#[cfg(test)]
5770mod builtin_catalog_validation {
5771 use super::*;
5772 use crate::schema::ModelSource;
5773
5774 fn weight_repo(source: &ModelSource) -> Option<&str> {
5776 match source {
5777 ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
5778 ModelSource::Local { hf_repo, .. } => Some(hf_repo),
5779 ModelSource::ManagedVllmMlx { hf_repo, .. } => Some(hf_repo),
5780 _ => None,
5781 }
5782 }
5783
5784 #[test]
5785 fn ids_are_unique() {
5786 let catalog = builtin_catalog();
5787 let mut seen: Vec<&str> = Vec::new();
5788 for model in &catalog {
5789 assert!(
5790 !seen.contains(&model.id.as_str()),
5791 "duplicate catalog id `{}` — the later entry silently shadows the earlier",
5792 model.id
5793 );
5794 seen.push(&model.id);
5795 }
5796 }
5797
5798 #[test]
5799 fn exact_frontier_rows_lock_native_selectors_and_digests() {
5800 let catalog = builtin_catalog();
5801 for (id, name, version, expected_digest) in [
5802 (
5803 "openai/gpt-5.5-2026-04-23",
5804 "gpt-5.5-2026-04-23",
5805 "2026-04-23",
5806 "aa1b0741114e6d9d0e1a758a3ab76005fe55dbcfe050d6a10a5dc37675b07b8f",
5807 ),
5808 (
5809 "anthropic/claude-opus-4-8",
5810 "claude-opus-4-8",
5811 "4.8",
5812 "10504959e51dc76c3563df91ae2eaba57cf814834ecd657232c50f264f9e735e",
5813 ),
5814 (
5815 "openai/gpt-5.6-sol:high",
5816 "gpt-5.6-sol:high",
5817 "latest",
5818 "6cc4a9a80708dbb99c8e4edb6c13be46b2b727a798cb354dcc5598d9004c4acd",
5819 ),
5820 ] {
5821 let row = catalog
5822 .iter()
5823 .find(|model| model.id == id)
5824 .unwrap_or_else(|| panic!("missing exact production row {id}"));
5825 assert_eq!(row.name, name);
5826 assert_eq!(row.version, version);
5827 assert_eq!(
5828 crate::catalog_identity::row_digest(row).unwrap(),
5829 expected_digest,
5830 "CAR row digest drifted for {id}"
5831 );
5832 }
5833 }
5834
5835 #[test]
5836 fn weight_repos_are_well_formed_huggingface_ids() {
5837 for model in builtin_catalog() {
5838 let Some(repo) = weight_repo(&model.source) else {
5839 continue;
5840 };
5841 assert_eq!(
5842 repo.split('/').count(),
5843 2,
5844 "{}: `{repo}` is not an `org/name` HuggingFace id",
5845 model.id
5846 );
5847 assert!(
5848 !repo.split('/').any(str::is_empty),
5849 "{}: `{repo}` has an empty path segment",
5850 model.id
5851 );
5852 assert!(
5853 !repo.contains(char::is_whitespace),
5854 "{}: `{repo}` contains whitespace",
5855 model.id
5856 );
5857 }
5858 }
5859
5860 #[test]
5864 fn param_counts_are_parseable_or_deliberately_empty() {
5865 for model in builtin_catalog() {
5866 if weight_repo(&model.source).is_none() || model.param_count.is_empty() {
5867 continue;
5868 }
5869 assert!(
5870 model.param_count.starts_with(|c: char| c.is_ascii_digit()),
5871 "{}: param_count `{}` does not start with a number, so the quality \
5872 prior cannot read it — leave it empty rather than descriptive",
5873 model.id,
5874 model.param_count
5875 );
5876 }
5877 }
5878
5879 #[test]
5882 fn catalog_vllm_mlx_entries_use_explicit_managed_ownership() {
5883 for model in builtin_catalog() {
5884 if !model.is_vllm_mlx() {
5885 continue;
5886 }
5887 assert!(
5888 model.is_car_managed_vllm_mlx(),
5889 "{}: a CAR-supervised catalog row must opt into ManagedVllmMlx; \
5890 loopback alone cannot confer ownership",
5891 model.id
5892 );
5893 }
5894 }
5895
5896 #[test]
5897 fn generate_capable_models_declare_a_context_window() {
5898 for model in builtin_catalog() {
5899 if !model.has_capability(crate::schema::ModelCapability::Generate) {
5900 continue;
5901 }
5902 assert!(
5903 model.context_length > 0,
5904 "{}: a generate-capable model with no context_length breaks budget sizing",
5905 model.id
5906 );
5907 }
5908 }
5909
5910 #[test]
5911 fn every_entry_declares_at_least_one_capability() {
5912 for model in builtin_catalog() {
5913 assert!(
5914 !model.capabilities.is_empty(),
5915 "{}: an entry with no capabilities can never be routed to",
5916 model.id
5917 );
5918 }
5919 }
5920}
5921
5922#[cfg(test)]
5923mod gguf_quantization_tests {
5924 use crate::schema::{QuantScheme, Quantization};
5925
5926 fn quantization_from_gguf_filename(name: &str) -> Option<Quantization> {
5927 Quantization::from_gguf_filename(name)
5928 }
5929
5930 #[test]
5931 fn reads_the_quantization_a_gguf_file_names() {
5932 let cases = [
5933 ("Qwen3-8B-Q4_K_M.gguf", "Q4_K_M", QuantScheme::KQuantMixed),
5934 (
5935 "Qwen3-Embedding-0.6B-Q8_0.gguf",
5936 "Q8_0",
5937 QuantScheme::RtnBlock,
5938 ),
5939 (
5940 "ggml-large-v3-turbo-q5_0.gguf",
5941 "q5_0",
5942 QuantScheme::RtnBlock,
5943 ),
5944 ("model-IQ4_XS.gguf", "IQ4_XS", QuantScheme::KQuantMixed),
5945 ];
5946 for (filename, label, scheme) in cases {
5947 let q = quantization_from_gguf_filename(filename)
5948 .unwrap_or_else(|| panic!("no quantization found in {filename}"));
5949 assert_eq!(q.label, label, "label for {filename}");
5950 assert_eq!(q.scheme, scheme, "scheme for {filename}");
5951 }
5952 }
5953
5954 #[test]
5956 fn returns_none_when_the_name_says_nothing() {
5957 for filename in ["model.gguf", "llama-2-7b-chat.gguf", "ggml-base.gguf"] {
5958 assert!(
5959 quantization_from_gguf_filename(filename).is_none(),
5960 "should not have guessed from {filename}"
5961 );
5962 }
5963 }
5964
5965 #[test]
5968 fn the_rightmost_match_wins() {
5969 let q = quantization_from_gguf_filename("q8-experiment-Q4_K_M.gguf").unwrap();
5970 assert_eq!(q.label, "Q4_K_M");
5971 }
5972}
5973
5974#[cfg(test)]
5975mod local_availability_tests {
5976 use super::*;
5977 use crate::schema::ModelSchema;
5978 use tempfile::TempDir;
5979
5980 fn gguf_row(id: &str, hf_repo: &str) -> ModelSchema {
5981 let mut schema: ModelSchema = serde_json::from_value(serde_json::json!({
5982 "id": id,
5983 "name": id.replace('/', "-"),
5984 "provider": "qwen",
5985 "family": "qwen3",
5986 "capabilities": ["generate"],
5987 "context_length": 32768,
5988 "param_count": "8B",
5989 "source": {
5990 "type": "local",
5991 "hf_repo": hf_repo,
5992 "hf_filename": "model.gguf",
5993 "tokenizer_repo": hf_repo,
5994 },
5995 "cost": { "size_mb": 4900 },
5996 }))
5997 .unwrap();
5998 schema.available = false;
5999 schema
6000 }
6001
6002 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6013 #[test]
6014 fn a_declared_repo_is_available_before_it_is_downloaded() {
6015 let _environment = crate::openrouter::test_environment_scope();
6016 let tmp = TempDir::new().unwrap();
6017 let models = tmp.path().join("models");
6018 std::fs::create_dir_all(&models).unwrap();
6019
6020 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6021 reg.register(gguf_row("qwen/test-8b:q4_k_m", "Qwen/Qwen3-8B-GGUF"));
6022
6023 let model = reg.get("qwen/test-8b:q4_k_m").expect("registered");
6024 assert!(
6025 model.available,
6026 "a GGUF model with a repo to fetch from must not report unavailable \
6027 just because nothing has downloaded it yet"
6028 );
6029 }
6030
6031 #[test]
6035 fn a_row_with_nowhere_to_fetch_from_stays_unavailable() {
6036 let _environment = crate::openrouter::test_environment_scope();
6037 let tmp = TempDir::new().unwrap();
6038 let models = tmp.path().join("models");
6039 std::fs::create_dir_all(&models).unwrap();
6040
6041 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6042 reg.register(gguf_row("local/scanned", ""));
6043
6044 let model = reg.get("local/scanned").expect("registered");
6045 assert!(
6046 !model.available,
6047 "an empty hf_repo has no download to promise"
6048 );
6049 }
6050}