1use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::time::SystemTime;
11
12use serde::{Deserialize, Serialize};
13use tracing::{debug, 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 credential_state_unknown: HashSet<String>,
161 codex_state_unknown: bool,
165 openrouter_state_unknown: bool,
167 project_model_ids: HashSet<String>,
170 signed_origin: HashMap<String, std::sync::Arc<crate::catalog::CatalogCacheEnvelope>>,
174 retained_ids: HashSet<String>,
177 revoked_ids: HashSet<String>,
180 revoked_builtin_ids: HashSet<String>,
183 signed_overlay_ids: HashSet<String>,
187 signed_catalog_ids: HashSet<String>,
191 builtin_model_ids: HashSet<String>,
194 user_config_ids: HashSet<String>,
202 on_disk_discovered_ids: HashSet<String>,
209 provider_discovered_ids: HashSet<String>,
212 user_config_path: PathBuf,
215 ambient_progress: ProgressSink,
229 session: SessionProbe,
234}
235
236#[derive(Debug, Clone, Deserialize)]
237struct ModelUpgradeRule {
238 from_ids: Vec<String>,
239 to_id: String,
240 reason: String,
241 target_runtime: Option<String>,
242 target_runtime_requirement: Option<String>,
243 #[serde(default)]
244 minimum_runtimes: Vec<ModelRuntimeRequirement>,
245 #[serde(default = "default_remove_old_after_available")]
246 remove_old_after_available: bool,
247}
248
249fn default_remove_old_after_available() -> bool {
250 true
251}
252
253fn environment_credential_available(env_var: &str) -> bool {
254 car_secrets::process_env_present(env_var)
255}
256
257pub(crate) fn credential_present_without_reading_it(env_var: &str) -> bool {
279 matches!(credential_presence(env_var), CredentialPresence::Present)
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub(crate) enum CredentialPresence {
290 Present,
291 Absent,
292 Unknown,
294}
295
296pub(crate) fn credential_presence(env_var: &str) -> CredentialPresence {
297 if environment_credential_available(env_var) {
298 return CredentialPresence::Present;
299 }
300 stored_credential_presence(env_var)
301}
302
303fn stored_credential_presence(env_var: &str) -> CredentialPresence {
304 match car_secrets::status_via_operator_broker_or_keychain(&car_secrets::SecretRef::new(
305 car_secrets::DEFAULT_SERVICE,
306 env_var,
307 )) {
308 Ok(status) if status.exists => CredentialPresence::Present,
309 Ok(_) => CredentialPresence::Absent,
310 Err(_) => CredentialPresence::Unknown,
311 }
312}
313
314static CODEX_SIGNED_IN_HINT: std::sync::atomic::AtomicBool =
319 std::sync::atomic::AtomicBool::new(false);
320
321pub fn remember_codex_sign_in(present: bool) {
326 CODEX_SIGNED_IN_HINT.store(present, std::sync::atomic::Ordering::Relaxed);
327}
328
329pub fn passive_codex_oauth_available() -> bool {
332 CODEX_SIGNED_IN_HINT.load(std::sync::atomic::Ordering::Relaxed)
333}
334
335fn passive_parslee_oauth_available() -> bool {
336 matches!(
337 car_auth::credential_authority_hint().state,
338 car_auth::CredentialAuthorityState::Configured
339 )
340}
341
342fn is_work_context_row(schema: &ModelSchema) -> bool {
345 schema.provider.eq_ignore_ascii_case("parslee")
346 && matches!(
347 &schema.source,
348 ModelSource::Proprietary {
349 provider,
350 auth: ProprietaryAuth::OAuth2Pkce { .. },
351 ..
352 } if provider.eq_ignore_ascii_case("parslee")
353 )
354}
355
356fn proprietary_auth_available(
362 model_id: &str,
363 schema_provider: &str,
364 source_provider: &str,
365 auth: &ProprietaryAuth,
366 parslee_oauth_available: bool,
367 resolved: &std::collections::HashMap<String, bool>,
368) -> bool {
369 if crate::openrouter::is_curated_managed_gateway_alias(model_id)
385 && crate::openrouter::gateway_unconfigured()
386 {
387 return false;
388 }
389 if crate::parslee_credential::credential_rejected()
403 && matches!(auth, ProprietaryAuth::OAuth2Pkce { .. })
404 {
405 return false;
406 }
407 match auth {
408 ProprietaryAuth::ApiKeyEnv { env_var } | ProprietaryAuth::BearerTokenEnv { env_var } => {
409 resolved.get(env_var).copied().unwrap_or(false)
410 }
411 ProprietaryAuth::OAuth2Pkce { .. } => {
412 schema_provider.eq_ignore_ascii_case("parslee")
413 && source_provider.eq_ignore_ascii_case("parslee")
414 && parslee_oauth_available
415 }
416 ProprietaryAuth::ChatGptSubscription {} => {
417 schema_provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
418 && source_provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
419 && passive_codex_oauth_available()
420 }
421 }
422}
423
424fn model_upgrade_rules() -> Vec<ModelUpgradeRule> {
425 serde_json::from_str(include_str!("../assets/model-upgrades.json"))
426 .expect("built-in model-upgrades.json should parse")
427}
428
429pub const USER_MODELS_FILE: &str = "models.json";
431
432pub fn user_config_path() -> Option<PathBuf> {
450 car_home::root().map(|root| root.join(USER_MODELS_FILE))
451}
452
453impl UnifiedRegistry {
454 pub fn new(models_dir: PathBuf) -> Self {
458 Self::new_with_state_root(car_home::root_or_relative(), models_dir)
459 }
460
461 pub fn new_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
465 let catalog_public_key = crate::catalog::catalog_public_key();
466 Self::new_with_catalog_public_key(state_root, models_dir, Some(&catalog_public_key))
467 }
468
469 fn new_with_catalog_public_key(
470 state_root: PathBuf,
471 models_dir: PathBuf,
472 catalog_public_key: Option<&str>,
473 ) -> Self {
474 #[cfg(not(test))]
482 let session = SessionProbe::Live;
483 #[cfg(test)]
484 let session = SessionProbe::Inert;
485 Self::new_with_session(state_root, models_dir, catalog_public_key, session)
486 }
487
488 pub(crate) fn new_with_session(
491 state_root: PathBuf,
492 models_dir: PathBuf,
493 catalog_public_key: Option<&str>,
494 session: SessionProbe,
495 ) -> Self {
496 let user_config_path = state_root.join(USER_MODELS_FILE);
497
498 let mut registry = Self {
499 models_dir,
500 state_root,
501 models: HashMap::new(),
502 credential_state_unknown: HashSet::new(),
503 codex_state_unknown: false,
504 openrouter_state_unknown: false,
505 project_model_ids: HashSet::new(),
506 signed_catalog_ids: HashSet::new(),
507 signed_overlay_ids: HashSet::new(),
508 signed_origin: HashMap::new(),
509 retained_ids: HashSet::new(),
510 revoked_ids: HashSet::new(),
511 revoked_builtin_ids: HashSet::new(),
512 builtin_model_ids: HashSet::new(),
513 user_config_ids: HashSet::new(),
514 on_disk_discovered_ids: HashSet::new(),
515 provider_discovered_ids: HashSet::new(),
516 user_config_path,
517 ambient_progress: ProgressSink::none(),
518 session,
519 };
520 registry.load_builtin_catalog();
521 let cached = crate::catalog::load_verified(
528 &crate::catalog::cache_path(®istry.state_root),
529 catalog_public_key,
530 );
531 if let Some(cached) = cached {
532 let origin = std::sync::Arc::new(cached.envelope());
533 let revoked: HashSet<String> = cached.revoked().iter().cloned().collect();
534 registry.replace_signed_catalog(
535 cached.into_models(),
536 Some(origin),
537 &HashSet::new(),
538 &revoked,
539 );
540 }
541 registry.restore_retained(crate::catalog::load_retained(
542 &crate::catalog::retained_path(®istry.state_root),
543 catalog_public_key,
544 ));
545 for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
553 ®istry.state_models_dir(),
554 )) {
555 if !registry.models.contains_key(&schema.id) && !registry.is_revoked(&schema.id) {
556 registry.provider_discovered_ids.insert(schema.id.clone());
557 registry.register(schema);
558 }
559 }
560 registry.refresh_availability();
561 let _ = registry.load_user_config();
563 registry.discover_on_disk_models();
567 registry
568 }
569
570 fn empty_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
571 let user_config_path = state_root.join(USER_MODELS_FILE);
572 Self {
573 models_dir,
574 state_root,
575 models: HashMap::new(),
576 credential_state_unknown: HashSet::new(),
577 codex_state_unknown: false,
578 openrouter_state_unknown: false,
579 project_model_ids: HashSet::new(),
580 signed_catalog_ids: HashSet::new(),
581 signed_overlay_ids: HashSet::new(),
582 signed_origin: HashMap::new(),
583 retained_ids: HashSet::new(),
584 revoked_ids: HashSet::new(),
585 revoked_builtin_ids: HashSet::new(),
586 builtin_model_ids: HashSet::new(),
587 user_config_ids: HashSet::new(),
588 on_disk_discovered_ids: HashSet::new(),
589 provider_discovered_ids: HashSet::new(),
590 user_config_path,
591 ambient_progress: ProgressSink::none(),
592 session: SessionProbe::Inert,
595 }
596 }
597
598 #[cfg(test)]
613 pub fn new_empty(models_dir: PathBuf) -> Self {
614 let state_root = models_dir.parent().unwrap_or(&models_dir).to_path_buf();
615 Self::empty_with_state_root(state_root, models_dir)
616 }
617
618 pub(crate) fn single(schema: ModelSchema) -> Self {
622 let scratch = std::env::temp_dir();
623 let mut registry = Self::empty_with_state_root(scratch.clone(), scratch);
624 registry.register(schema);
625 registry
626 }
627
628 #[cfg(test)]
633 pub(crate) fn set_available_for_test(&mut self, id: &str, available: bool) {
634 if let Some(row) = self.models.get_mut(id) {
635 row.available = available;
636 }
637 }
638
639 pub(crate) fn new_isolated_for_diagnosis(state_root: PathBuf, models_dir: PathBuf) -> Self {
646 let mut registry = Self::empty_with_state_root(state_root, models_dir);
647 registry.discover_on_disk_models();
648 registry
649 }
650
651 fn state_models_dir(&self) -> PathBuf {
656 self.state_root.join("models")
657 }
658
659 fn discover_on_disk_models(&mut self) {
673 let entries = match std::fs::read_dir(&self.models_dir) {
674 Ok(e) => e,
675 Err(_) => return,
676 };
677 let known: std::collections::HashSet<String> = self
682 .models
683 .values()
684 .map(|m| m.name.to_ascii_lowercase())
685 .chain(
686 crate::catalog::load_revoked_names(&crate::catalog::revoked_names_path(
687 &self.state_root,
688 ))
689 .into_keys()
690 .map(|name| name.to_ascii_lowercase()),
691 )
692 .collect();
693
694 for entry in entries.flatten() {
695 let path = entry.path();
696 if !path.is_dir() {
697 continue;
698 }
699 let Some(name) = path
700 .file_name()
701 .and_then(|n| n.to_str())
702 .map(str::to_string)
703 else {
704 continue;
705 };
706 if known.contains(&name.to_ascii_lowercase()) {
707 continue;
708 }
709
710 let Some(schema) = synthesize_local_schema(&name, &path) else {
711 continue;
712 };
713 if self.models.contains_key(&schema.id) {
719 tracing::debug!(
720 id = %schema.id,
721 name = %name,
722 "skipping local model directory whose id is already registered (car#1650)"
723 );
724 continue;
725 }
726 tracing::info!(
727 id = %schema.id,
728 name = %name,
729 "auto-discovered uncatalogued local model under models_dir (car-releases#62)"
730 );
731 let id = schema.id.clone();
732 self.register(schema);
733 if self.models.contains_key(&id) {
734 self.on_disk_discovered_ids.insert(id);
735 }
736 }
737 }
738
739 pub(crate) fn prune_missing_on_disk_models(&mut self) {
746 let missing = self
747 .on_disk_discovered_ids
748 .iter()
749 .filter(|id| {
750 self.models
751 .get(id.as_str())
752 .is_none_or(|schema| !self.models_dir.join(&schema.name).is_dir())
753 })
754 .cloned()
755 .collect::<Vec<_>>();
756 for id in missing {
757 tracing::debug!(
758 model_id = %id,
759 "dropping vanished auto-discovered model; coordination records do not register models"
760 );
761 self.on_disk_discovered_ids.remove(&id);
762 self.models.remove(&id);
763 }
764 }
765
766 pub fn register(&mut self, mut schema: ModelSchema) {
774 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
775 warn!(id = %schema.id, "ignoring user registration for reserved Parslee-managed alias");
776 return;
777 }
778 if self.project_model_ids.contains(&schema.id) {
779 warn!(id = %schema.id, "ignoring public registration for project-owned exact id");
780 return;
781 }
782 schema.mark_user_registered();
783 let id = schema.id.clone();
784 if self.register_preserving_trust(schema) {
785 self.on_disk_discovered_ids.remove(&id);
786 }
787 }
788
789 pub(crate) fn register_project_model(&mut self, schema: ModelSchema) -> bool {
795 let id = schema.id.clone();
796 if !self.register_preserving_trust(schema) {
797 return false;
798 }
799 self.on_disk_discovered_ids.remove(&id);
800 self.project_model_ids.insert(id);
801 true
802 }
803
804 pub(crate) fn replace_signed_catalog(
820 &mut self,
821 rows: Vec<ModelSchema>,
822 origin: Option<std::sync::Arc<crate::catalog::CatalogCacheEnvelope>>,
823 keep: &HashSet<String>,
824 revoked: &HashSet<String>,
825 ) -> usize {
826 let incoming: HashSet<&str> = rows.iter().map(|m| m.id.as_str()).collect();
827 for id in std::mem::take(&mut self.signed_catalog_ids) {
828 if keep.contains(&id) && !incoming.contains(id.as_str()) && !revoked.contains(&id) {
831 if let Some(row) = self.models.get_mut(&id) {
832 row.deprecated = true;
833 }
834 self.retained_ids.insert(id.clone());
835 self.signed_catalog_ids.insert(id);
836 continue;
837 }
838 self.models.remove(&id);
839 self.project_model_ids.remove(&id);
840 self.credential_state_unknown.remove(&id);
841 self.signed_origin.remove(&id);
842 self.retained_ids.remove(&id);
843 }
844 let overlaid = std::mem::take(&mut self.signed_overlay_ids);
845 let unrevoked = std::mem::take(&mut self.revoked_builtin_ids);
846 let restore: HashSet<String> = overlaid.union(&unrevoked).cloned().collect();
847 let compiled: HashMap<String, ModelSchema> = if restore.is_empty() {
848 HashMap::new()
849 } else {
850 builtin_catalog()
851 .into_iter()
852 .filter(|m| restore.contains(&m.id))
853 .map(|m| (m.id.clone(), m))
854 .collect()
855 };
856 for id in &restore {
857 if let (Some(row), Some(original)) = (self.models.get_mut(id), compiled.get(id)) {
858 Self::copy_overlay_fields(original, row);
859 }
860 }
861 let lifted: HashSet<String> = std::mem::replace(&mut self.revoked_ids, revoked.clone())
862 .difference(revoked)
863 .cloned()
864 .collect();
865 for id in revoked {
868 if self.provider_discovered_ids.remove(id) && !self.user_config_ids.contains(id) {
869 self.models.remove(id);
870 }
871 }
872 let mut loaded = 0;
873 for schema in rows {
874 let id = schema.id.clone();
875 if revoked.contains(&id) {
876 warn!(%id, "ignoring a signed catalog row its own catalog revokes");
877 continue;
878 }
879 if self.register_signed_catalog_model(schema) {
880 loaded += 1;
881 if let Some(origin) = &origin {
882 if self.signed_catalog_ids.contains(&id) {
883 self.signed_origin.insert(id, origin.clone());
884 }
885 }
886 }
887 }
888 if !lifted.is_empty() {
891 for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
892 &self.state_models_dir(),
893 )) {
894 if lifted.contains(&schema.id) && !self.models.contains_key(&schema.id) {
895 self.provider_discovered_ids.insert(schema.id.clone());
896 self.register(schema);
897 }
898 }
899 }
900 for id in revoked {
903 if self.builtin_model_ids.contains(id) {
904 if let Some(row) = self.models.get_mut(id) {
905 row.deprecated = true;
906 self.revoked_builtin_ids.insert(id.clone());
907 }
908 }
909 }
910 loaded
911 }
912
913 pub fn is_revoked(&self, id: &str) -> bool {
915 self.revoked_ids.contains(id)
916 }
917
918 pub fn revoked_ids(&self) -> Vec<String> {
920 self.revoked_ids.iter().cloned().collect()
921 }
922
923 pub fn is_builtin(&self, id: &str) -> bool {
926 self.builtin_model_ids.contains(id)
927 }
928
929 pub(crate) fn retained_rows(
932 &self,
933 ) -> Vec<(String, std::sync::Arc<crate::catalog::CatalogCacheEnvelope>)> {
934 let mut rows: Vec<_> = self
935 .retained_ids
936 .iter()
937 .filter_map(|id| Some((id.clone(), self.signed_origin.get(id)?.clone())))
938 .collect();
939 rows.sort_by(|a, b| a.0.cmp(&b.0));
940 rows
941 }
942
943 pub(crate) fn signed_origin_of(
945 &self,
946 id: &str,
947 ) -> Option<std::sync::Arc<crate::catalog::CatalogCacheEnvelope>> {
948 self.signed_origin.get(id).cloned()
949 }
950
951 pub(crate) fn restore_retained(
955 &mut self,
956 rows: Vec<(
957 ModelSchema,
958 std::sync::Arc<crate::catalog::CatalogCacheEnvelope>,
959 )>,
960 ) {
961 for (mut schema, origin) in rows {
962 if self.models.contains_key(&schema.id) || self.revoked_ids.contains(&schema.id) {
963 continue;
964 }
965 schema.deprecated = true;
966 let id = schema.id.clone();
967 if self.register_signed_catalog_model(schema) && self.signed_catalog_ids.contains(&id) {
968 self.signed_origin.insert(id.clone(), origin);
969 self.retained_ids.insert(id);
970 }
971 }
972 }
973
974 fn copy_overlay_fields(from: &ModelSchema, to: &mut ModelSchema) {
977 to.public_benchmarks = from.public_benchmarks.clone();
978 to.cost = from.cost.clone();
979 to.performance = from.performance.clone();
980 to.deprecated = from.deprecated;
981 }
982
983 fn overlay_builtin(&mut self, schema: &ModelSchema) -> bool {
987 let Some(row) = self.models.get_mut(&schema.id) else {
988 return false;
989 };
990 let same_weights =
991 serde_json::to_value(&row.source).ok() == serde_json::to_value(&schema.source).ok();
992 if !same_weights {
993 warn!(id = %schema.id, "ignoring signed overlay that names different weights for a compiled builtin");
994 return false;
995 }
996 Self::copy_overlay_fields(schema, row);
997 self.signed_overlay_ids.insert(schema.id.clone());
998 true
999 }
1000
1001 fn register_signed_catalog_model(&mut self, schema: ModelSchema) -> bool {
1005 if self.builtin_model_ids.contains(&schema.id) {
1006 if self.signed_overlay_ids.contains(&schema.id) {
1007 warn!(id = %schema.id, "ignoring duplicate signed overlay for compiled builtin");
1008 return false;
1009 }
1010 return self.overlay_builtin(&schema);
1011 }
1012 if self.user_config_ids.contains(&schema.id) {
1013 warn!(id = %schema.id, "ignoring signed catalog row for a user-registered id");
1014 return false;
1015 }
1016 if self.project_model_ids.contains(&schema.id) {
1017 warn!(id = %schema.id, "ignoring duplicate signed catalog row for project-owned exact id");
1018 return false;
1019 }
1020 let id = schema.id.clone();
1021 let registered = self.register_project_model(schema);
1022 if registered {
1023 self.signed_catalog_ids.insert(id);
1024 }
1025 registered
1026 }
1027
1028 fn register_preserving_trust(&mut self, mut schema: ModelSchema) -> bool {
1029 if let Err(error) = crate::catalog_identity::row_digest(&schema) {
1030 warn!(id = %schema.id, %error, "rejecting model without canonical catalog identity");
1031 return false;
1032 }
1033 if schema.is_mlx() || schema.is_car_managed_vllm_mlx() {
1035 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1046 {
1047 schema.available = if schema.tags.contains(&"speech".to_string()) {
1048 speech_mlx_available()
1049 } else if let ModelSource::Mlx { ref hf_repo, .. }
1050 | ModelSource::ManagedVllmMlx { ref hf_repo, .. } = schema.source
1051 {
1052 let loader_ok = !mlx_row_needs_in_process_loader(&schema)
1066 || crate::backend::local::in_process_loader_available();
1067 let mlx_dir = self.models_dir.join(&schema.name);
1068 loader_ok && (mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty())
1069 } else {
1070 let mlx_dir = self.models_dir.join(&schema.name);
1071 mlx_dir_has_weights(&mlx_dir)
1072 };
1073 }
1074 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1075 {
1076 schema.available = false;
1077 }
1078 } else if schema.is_vllm_mlx() {
1079 schema.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available;
1081 } else if matches!(schema.source, ModelSource::WhisperCpp { .. }) {
1082 schema.available = true;
1087 } else if matches!(schema.source, ModelSource::WindowsSpeech {}) {
1088 schema.available = cfg!(target_os = "windows");
1090 } else if schema.is_codex_cli() {
1091 schema.available = crate::backend::codex_cli::is_available();
1095 } else if schema.is_local() {
1096 let local_path = self.models_dir.join(&schema.name).join("model.gguf");
1097 let lazily_fetchable = matches!(
1103 schema.source,
1104 ModelSource::Local { ref hf_repo, .. } if !hf_repo.is_empty()
1105 ) && !cfg!(all(
1106 target_os = "macos",
1107 target_arch = "aarch64",
1108 not(car_skip_mlx)
1109 ));
1110 schema.available = local_path.exists() || lazily_fetchable;
1111 } else if schema.is_remote() {
1112 schema.available = match schema.source {
1116 ModelSource::RemoteApi {
1117 protocol: crate::schema::ApiProtocol::OpenRouter,
1118 ..
1119 } => crate::openrouter::credential_source().is_some(),
1120 ModelSource::RemoteApi {
1121 ref api_key_env, ..
1122 } => environment_credential_available(api_key_env),
1123 ModelSource::Proprietary {
1124 ref provider,
1125 ref auth,
1126 ..
1127 } => {
1128 let resolved = match auth {
1129 ProprietaryAuth::ApiKeyEnv { env_var }
1130 | ProprietaryAuth::BearerTokenEnv { env_var } => {
1131 std::collections::HashMap::from([(
1132 env_var.clone(),
1133 environment_credential_available(env_var),
1134 )])
1135 }
1136 ProprietaryAuth::OAuth2Pkce { .. }
1137 | ProprietaryAuth::ChatGptSubscription {} => Default::default(),
1138 };
1139 proprietary_auth_available(
1140 &schema.id,
1141 &schema.provider,
1142 provider,
1143 auth,
1144 self.session.available(),
1145 &resolved,
1146 )
1147 }
1148 _ => schema.available,
1149 };
1150 }
1151 if crate::typesafe::is_direct(&schema) {
1154 schema.available = false;
1155 }
1156 schema.weights_ready = physical_weights_ready(&schema, &self.models_dir);
1169 debug!(
1181 id = %schema.id,
1182 name = %schema.name,
1183 available = schema.available,
1184 weights_ready = schema.weights_ready,
1185 "registered model"
1186 );
1187 self.models.insert(schema.id.clone(), schema);
1188 true
1189 }
1190
1191 pub fn register_user_model(&mut self, mut schema: ModelSchema) {
1198 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
1199 warn!(id = %schema.id, "ignoring persisted user model for reserved Parslee-managed alias");
1200 return;
1201 }
1202 if self.project_model_ids.contains(&schema.id) {
1203 warn!(id = %schema.id, "ignoring persisted user model for project-owned exact id");
1204 return;
1205 }
1206 schema.mark_user_registered();
1207 let id = schema.id.clone();
1208 if self.register_preserving_trust(schema) {
1209 self.on_disk_discovered_ids.remove(&id);
1210 self.provider_discovered_ids.remove(&id);
1211 self.user_config_ids.insert(id);
1212 }
1213 }
1214
1215 pub fn unregister(&mut self, id: &str) -> Option<ModelSchema> {
1217 self.on_disk_discovered_ids.remove(id);
1218 self.provider_discovered_ids.remove(id);
1219 let removed = self.models.remove(id);
1220 if let Some(ref m) = removed {
1221 info!(id = %m.id, "unregistered model");
1222 }
1223 removed
1224 }
1225
1226 pub fn unregister_user_model(&mut self, id: &str) -> Option<ModelSchema> {
1231 if !self.user_config_ids.remove(id) {
1232 return None;
1233 }
1234 self.unregister(id)
1235 }
1236
1237 pub fn list(&self) -> Vec<&ModelSchema> {
1239 let mut models: Vec<&ModelSchema> = self.models.values().collect();
1240 models.sort_by(|a, b| a.id.cmp(&b.id));
1241 models
1242 }
1243
1244 pub fn query(&self, filter: &ModelFilter) -> Vec<&ModelSchema> {
1246 self.models
1247 .values()
1248 .filter(|m| {
1249 if !filter.capabilities.iter().all(|c| m.has_capability(*c)) {
1251 return false;
1252 }
1253 if let Some(max) = filter.max_size_mb {
1255 if m.size_mb() > max && m.is_local() {
1256 return false;
1257 }
1258 }
1259 if let Some(max) = filter.max_latency_ms {
1261 if let Some(p50) = m.performance.latency_p50_ms {
1262 if p50 > max {
1263 return false;
1264 }
1265 }
1266 }
1267 if let Some(max) = filter.max_cost_per_mtok {
1269 if let Some(cost) = m.cost.output_per_mtok {
1270 if cost > max {
1271 return false;
1272 }
1273 }
1274 }
1275 if !filter.tags.iter().all(|t| m.tags.contains(t)) {
1277 return false;
1278 }
1279 if let Some(ref p) = filter.provider {
1281 if &m.provider != p {
1282 return false;
1283 }
1284 }
1285 if filter.local_only && !m.is_local() {
1287 return false;
1288 }
1289 if filter.available_only && !m.available_now() {
1291 return false;
1292 }
1293 true
1294 })
1295 .collect()
1296 }
1297
1298 pub fn query_by_capability(&self, cap: ModelCapability) -> Vec<&ModelSchema> {
1300 self.query(&ModelFilter {
1301 capabilities: vec![cap],
1302 ..Default::default()
1303 })
1304 }
1305
1306 pub fn available_upgrades(&self) -> Vec<ModelUpgrade> {
1308 let mut upgrades = Vec::new();
1309 for rule in model_upgrade_rules() {
1310 let Some(from) = rule
1311 .from_ids
1312 .iter()
1313 .find_map(|id| self.models.get(id.as_str()))
1314 .filter(|schema| schema.available)
1315 else {
1316 continue;
1317 };
1318 let Some(to) = self.models.get(rule.to_id.as_str()) else {
1319 continue;
1320 };
1321 upgrades.push(ModelUpgrade {
1322 from_id: from.id.clone(),
1323 from_name: from.name.clone(),
1324 to_id: to.id.clone(),
1325 to_name: to.name.clone(),
1326 reason: rule.reason.clone(),
1327 target_runtime: rule.target_runtime.clone(),
1328 target_runtime_requirement: rule.target_runtime_requirement.clone(),
1329 minimum_runtimes: rule.minimum_runtimes.clone(),
1330 target_available: to.available,
1331 target_pullable: matches!(
1332 to.source,
1333 ModelSource::Local { .. } | ModelSource::Mlx { .. }
1334 ),
1335 remove_old_supported: matches!(
1336 from.source,
1337 ModelSource::Local { .. } | ModelSource::Mlx { .. }
1338 ) && rule.remove_old_after_available,
1339 });
1340 }
1341 upgrades.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
1342 upgrades.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
1343 upgrades
1344 }
1345
1346 pub(crate) fn curated_replacement(&self, id: &str) -> Option<&ModelSchema> {
1353 model_upgrade_rules()
1354 .into_iter()
1355 .find(|rule| rule.from_ids.iter().any(|from| from == id))
1356 .and_then(|rule| self.models.get(rule.to_id.as_str()))
1357 .filter(|to| to.id != id)
1358 }
1359
1360 pub fn get(&self, id: &str) -> Option<&ModelSchema> {
1362 self.models.get(id)
1363 }
1364
1365 pub(crate) fn get_by_id_or_name(&self, model: &str) -> Option<&ModelSchema> {
1368 self.get(model).or_else(|| self.find_by_name(model))
1369 }
1370
1371 pub fn registered_schema(&self, id: &str) -> Option<&ModelSchema> {
1377 self.get(id)
1378 }
1379
1380 pub fn all(&self) -> impl Iterator<Item = &ModelSchema> {
1382 self.models.values()
1383 }
1384
1385 pub fn find_by_name(&self, name: &str) -> Option<&ModelSchema> {
1396 find_named(name, self.models.values(), |m| &m.name).or_else(|| {
1397 if self.models.contains_key(name) {
1398 return None;
1399 }
1400 self.models.get(&implicit_latest_id(name)?)
1401 })
1402 }
1403
1404 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1408 pub fn resolve_mlx_equivalent(&self, schema: &ModelSchema) -> Option<&ModelSchema> {
1409 if schema.is_mlx() || schema.is_vllm_mlx() {
1411 return None;
1412 }
1413 if !matches!(schema.source, ModelSource::Local { .. }) {
1415 return None;
1416 }
1417 let primary_cap = schema.capabilities.first()?;
1424 self.models.values().find(|m| {
1425 m.is_mlx()
1426 && m.family == schema.family
1427 && m.param_count == schema.param_count
1428 && m.capabilities.contains(primary_cap)
1429 })
1430 }
1431
1432 pub async fn ensure_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1434 let sink = self.ambient_progress.clone();
1439 self.ensure_local_with_progress(id, &sink).await
1440 }
1441
1442 pub fn set_ambient_progress(&mut self, sink: ProgressSink) {
1449 self.ambient_progress = sink;
1450 }
1451
1452 pub async fn ensure_local_with_progress(
1459 &self,
1460 id: &str,
1461 sink: &ProgressSink,
1462 ) -> Result<PathBuf, InferenceError> {
1463 self.acquire_and_ensure(id, sink, false, None).await
1464 }
1465
1466 pub(crate) async fn ensure_local_with_progress_staged(
1471 &self,
1472 id: &str,
1473 sink: &ProgressSink,
1474 staging_dir: &Path,
1475 ) -> Result<PathBuf, InferenceError> {
1476 self.acquire_and_ensure(id, sink, false, Some(staging_dir))
1477 .await
1478 }
1479
1480 pub async fn redownload_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1486 self.acquire_and_ensure(id, &ProgressSink::none(), true, None)
1487 .await
1488 }
1489
1490 async fn acquire_and_ensure(
1491 &self,
1492 id: &str,
1493 sink: &ProgressSink,
1494 force: bool,
1495 managed_dir_override: Option<&Path>,
1496 ) -> Result<PathBuf, InferenceError> {
1497 let schema = self
1498 .get(id)
1499 .or_else(|| self.find_by_name(id))
1500 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1501 if never_download_in_scope() {
1506 if !force && managed_dir_override.is_none() {
1507 if let Some(path) = self.try_reuse_local(schema).await? {
1508 return Ok(path);
1509 }
1510 }
1511 return Err(refuse_download(schema));
1512 }
1513 let model_name = schema.name.clone();
1514 let model_id = schema.id.clone();
1515 let needed_mb = schema.size_mb();
1516 let model_dir = self.models_dir.join(&schema.name);
1517
1518 let _guard = crate::download::acquire_model_lock(&model_id).await;
1520
1521 if !force && managed_dir_override.is_none() {
1525 if let Some(path) = self.try_reuse_local(schema).await? {
1526 return Ok(path);
1527 }
1528 }
1529
1530 if let Err(e) = crate::download::check_disk_space(&model_dir, needed_mb) {
1532 sink.emit(DownloadEvent::Failed { error: e.clone() });
1533 return Err(InferenceError::DownloadFailed(e));
1534 }
1535
1536 sink.emit(DownloadEvent::Started {
1537 model: model_name.clone(),
1538 total_files: 0,
1539 total_mb: needed_mb,
1540 });
1541 let (result, fetched) = crate::retire::collecting_fetches(self.ensure_local_inner(
1544 id,
1545 sink,
1546 force,
1547 managed_dir_override,
1548 ))
1549 .await;
1550 crate::retire::record_download(
1551 &self.state_root,
1552 &crate::hf_cache::hub_dir(),
1553 &model_id,
1554 &fetched,
1555 );
1556 match &result {
1557 Ok(_) => sink.emit(DownloadEvent::Completed { model: model_name }),
1558 Err(e) => sink.emit(DownloadEvent::Failed {
1559 error: e.to_string(),
1560 }),
1561 }
1562 result
1563 }
1564
1565 async fn try_reuse_local(
1566 &self,
1567 schema: &ModelSchema,
1568 ) -> Result<Option<PathBuf>, InferenceError> {
1569 let reused = self.reusable_local_dir(schema);
1570 if let Some(path) = &reused {
1571 info!(model = %schema.name, path = %path.display(), "using local model files already on disk");
1572 }
1573 Ok(reused)
1574 }
1575
1576 fn reusable_local_dir(&self, schema: &ModelSchema) -> Option<PathBuf> {
1579 match &schema.source {
1580 ModelSource::Local { .. } => {
1581 let model_dir = self.models_dir.join(&schema.name);
1582 let model_path = model_dir.join("model.gguf");
1583 let tokenizer_path = model_dir.join("tokenizer.json");
1584
1585 if crate::download::cache_file_usable(&model_path)
1586 && crate::download::cache_file_usable(&tokenizer_path)
1587 {
1588 return Some(model_dir);
1589 }
1590 }
1591 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1592 let model_dir = self.models_dir.join(&schema.name);
1593 let is_diffusers = mlx_schema_is_diffusers(schema);
1594
1595 if mlx_layout_complete(is_diffusers, &model_dir) {
1596 if auxiliary_mlx_files_missing(&schema.name, hf_repo, &model_dir) {
1597 return None;
1601 }
1602 return Some(model_dir);
1603 }
1604
1605 if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1606 .filter(|d| mlx_layout_complete(is_diffusers, d))
1607 {
1608 if !auxiliary_mlx_files_missing(&schema.name, hf_repo, &snapshot_dir) {
1609 return Some(snapshot_dir);
1610 }
1611 }
1612 }
1613 _ => {}
1614 }
1615
1616 None
1617 }
1618
1619 async fn ensure_local_inner(
1620 &self,
1621 id: &str,
1622 sink: &ProgressSink,
1623 force: bool,
1624 managed_dir_override: Option<&Path>,
1625 ) -> Result<PathBuf, InferenceError> {
1626 let schema = self
1627 .get(id)
1628 .or_else(|| self.find_by_name(id))
1629 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1630
1631 match &schema.source {
1632 ModelSource::Local {
1633 hf_repo,
1634 hf_filename,
1635 tokenizer_repo,
1636 } => {
1637 let model_dir = managed_dir_override
1638 .map(Path::to_path_buf)
1639 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1640 let model_path = model_dir.join("model.gguf");
1641 let tokenizer_path = model_dir.join("tokenizer.json");
1642
1643 if !force
1644 && crate::download::cache_file_usable(&model_path)
1645 && crate::download::cache_file_usable(&tokenizer_path)
1646 {
1647 return Ok(model_dir);
1648 }
1649
1650 if hf_repo.is_empty() {
1657 let missing = [
1658 ("model.gguf", &model_path),
1659 ("tokenizer.json", &tokenizer_path),
1660 ]
1661 .into_iter()
1662 .filter(|(_, path)| !crate::download::cache_file_usable(path))
1663 .map(|(name, _)| name)
1664 .collect::<Vec<_>>()
1665 .join(" and ");
1666 return Err(InferenceError::InferenceFailed(format!(
1667 "{}: discovered on disk at {} but not loadable — the GGUF \
1668 backend reads `model.gguf` and `tokenizer.json` from the \
1669 model directory, and this one is missing {missing}. Rename \
1670 the weight file to `model.gguf` and add the tokenizer, or \
1671 register the model against its HuggingFace repo so CAR can \
1672 fetch both.",
1673 schema.name,
1674 model_dir.display()
1675 )));
1676 }
1677
1678 std::fs::create_dir_all(&model_dir)?;
1679
1680 if !crate::download::cache_file_usable(&model_path) {
1681 info!(model = %schema.name, repo = %hf_repo, "downloading model weights");
1682 sink.emit(DownloadEvent::FileStarted {
1683 filename: "model weights".into(),
1684 index: 1,
1685 total_files: 2,
1686 size_mb: schema.size_mb(),
1687 });
1688 download_file(hf_repo, hf_filename, &model_path).await?;
1689 sink.emit(DownloadEvent::FileCompleted {
1690 filename: "model weights".into(),
1691 });
1692 }
1693 if !crate::download::cache_file_usable(&tokenizer_path) {
1694 info!(model = %schema.name, repo = %tokenizer_repo, "downloading tokenizer");
1695 sink.emit(DownloadEvent::FileStarted {
1696 filename: "tokenizer".into(),
1697 index: 2,
1698 total_files: 2,
1699 size_mb: 0,
1700 });
1701 download_file(tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
1702 sink.emit(DownloadEvent::FileCompleted {
1703 filename: "tokenizer".into(),
1704 });
1705 }
1706
1707 Ok(model_dir)
1708 }
1709 ModelSource::Mlx {
1710 hf_repo,
1711 hf_weight_file,
1712 }
1713 | ModelSource::ManagedVllmMlx {
1714 hf_repo,
1715 hf_weight_file,
1716 ..
1717 } => {
1718 let model_dir = managed_dir_override
1719 .map(Path::to_path_buf)
1720 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1721 let config_path = model_dir.join("config.json");
1722
1723 let is_diffusers = mlx_schema_is_diffusers(schema);
1734
1735 if !force && mlx_layout_complete(is_diffusers, &model_dir) {
1746 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1747 info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1748 return Ok(model_dir);
1749 }
1750
1751 if !force {
1755 if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1756 .filter(|d| mlx_layout_complete(is_diffusers, d))
1757 {
1758 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &snapshot_dir).await?;
1759 info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1760 return Ok(snapshot_dir);
1761 }
1762 }
1763
1764 std::fs::create_dir_all(&model_dir)?;
1765
1766 info!(model = %schema.name, repo = %hf_repo, "downloading MLX model");
1767
1768 if is_diffusers {
1775 download_repo_snapshot(hf_repo, &model_dir, sink).await?;
1776 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1777 if !mlx_dir_has_weights(&model_dir) {
1778 return Err(InferenceError::DownloadFailed(format!(
1779 "{hf_repo}: snapshot fetched but no component weights found"
1780 )));
1781 }
1782 info!(model = %schema.name, path = %model_dir.display(), "downloaded diffusers model");
1783 return Ok(model_dir);
1784 }
1785
1786 emit_file(sink, "config", 0, schema.size_mb());
1790 download_file(hf_repo, "config.json", &config_path).await?;
1791 download_tokenizer_assets(hf_repo, &model_dir, sink).await;
1792 let tok_config_path = model_dir.join("tokenizer_config.json");
1793 if !crate::download::cache_file_usable(&tok_config_path) {
1794 let _ = download_file(hf_repo, "tokenizer_config.json", &tok_config_path).await;
1795 }
1796
1797 if let Some(ref wf) = hf_weight_file {
1799 let wf_path = model_dir.join(wf);
1800 if !crate::download::cache_file_usable(&wf_path) {
1801 emit_file(sink, "model weights", 0, schema.size_mb());
1802 download_file(hf_repo, wf, &wf_path).await?;
1803 }
1804 } else {
1805 let single = model_dir.join("model.safetensors");
1807 if !crate::download::cache_file_usable(&single) {
1808 emit_file(sink, "model weights", 0, schema.size_mb());
1809 match download_file(hf_repo, "model.safetensors", &single).await {
1810 Ok(()) => {}
1811 Err(_) => {
1812 let index_path = model_dir.join("model.safetensors.index.json");
1814 download_file(hf_repo, "model.safetensors.index.json", &index_path)
1815 .await?;
1816
1817 let index_json: serde_json::Value =
1818 serde_json::from_str(&std::fs::read_to_string(&index_path)?)
1819 .map_err(|e| {
1820 InferenceError::InferenceFailed(format!(
1821 "parse index: {e}"
1822 ))
1823 })?;
1824
1825 if let Some(weight_map) =
1826 index_json.get("weight_map").and_then(|m| m.as_object())
1827 {
1828 let mut files: std::collections::HashSet<String> =
1829 std::collections::HashSet::new();
1830 for filename in weight_map.values() {
1831 if let Some(f) = filename.as_str() {
1832 files.insert(f.to_string());
1833 }
1834 }
1835 let shard_total = files.len() as u32;
1836 for (i, file) in files.iter().enumerate() {
1837 let dest = model_dir.join(file);
1838 if !crate::download::cache_file_usable(&dest) {
1839 info!(file = %file, "downloading weight shard");
1840 sink.emit(DownloadEvent::FileStarted {
1841 filename: format!("weights part {}", i + 1),
1842 index: (i + 1) as u32,
1843 total_files: shard_total,
1844 size_mb: 0,
1845 });
1846 download_file(hf_repo, file, &dest).await?;
1847 sink.emit(DownloadEvent::FileCompleted {
1848 filename: format!("weights part {}", i + 1),
1849 });
1850 }
1851 }
1852 }
1853 }
1854 }
1855 }
1856 }
1857
1858 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1859
1860 let missing = missing_weight_shards(&model_dir);
1872 if !missing.is_empty() {
1873 return Err(InferenceError::DownloadFailed(format!(
1874 "{}: pull finished but {} weight shard(s) are still missing: {}. \
1875 The download was interrupted; re-run the pull to resume it.",
1876 schema.name,
1877 missing.len(),
1878 missing.join(", ")
1879 )));
1880 }
1881 if !mlx_dir_has_weights(&model_dir) {
1882 return Err(InferenceError::DownloadFailed(format!(
1883 "{}: pull finished but no usable weights are present under {}",
1884 schema.name,
1885 model_dir.display()
1886 )));
1887 }
1888 Ok(model_dir)
1889 }
1890 _ => Err(InferenceError::InferenceFailed(format!(
1891 "model {} is not local",
1892 id
1893 ))),
1894 }
1895 }
1896
1897 #[deprecated(note = "use InferenceEngine::remove_model_from_car")]
1901 pub fn remove_local(&mut self, id: &str) -> Result<(), InferenceError> {
1902 Err(InferenceError::InferenceFailed(format!(
1903 "legacy registry removal for {id} is disabled; use receipt-backed model management"
1904 )))
1905 }
1906
1907 pub fn refresh_availability(&mut self) {
1914 let parslee_oauth_available = self.session.available();
1915 self.refresh_availability_with(
1916 parslee_oauth_available,
1917 self.session.signed_out() && self.session.may_forget_session_evidence(),
1918 false,
1919 );
1920 }
1921
1922 pub fn refresh_availability_for_explicit_status(&mut self) {
1940 let parslee_oauth_available = self.session.available();
1941 self.refresh_availability_with(
1942 parslee_oauth_available,
1943 self.session.signed_out() && self.session.may_forget_session_evidence(),
1944 true,
1945 );
1946 }
1947
1948 pub(crate) fn refresh_routing_availability(
1953 &mut self,
1954 parslee_api_base: Option<&str>,
1955 parslee_signed_out: bool,
1956 ) {
1957 if let Some(api_base) = parslee_api_base {
1958 let api_base = api_base.trim_end_matches('/');
1959 for schema in self.models.values_mut() {
1960 if schema.provider.eq_ignore_ascii_case("parslee") {
1961 if let ModelSource::Proprietary {
1962 provider, endpoint, ..
1963 } = &mut schema.source
1964 {
1965 if provider.eq_ignore_ascii_case("parslee") {
1966 *endpoint = api_base.to_string();
1967 }
1968 }
1969 }
1970 }
1971 }
1972 self.refresh_availability_with(parslee_api_base.is_some(), parslee_signed_out, true);
1973 }
1974
1975 pub(crate) fn refresh_work_context_availability(&mut self, api_base: &str, available: bool) {
1978 self.refresh_availability();
1979 for schema in self.models.values_mut() {
1980 if !is_work_context_row(schema) {
1981 continue;
1982 }
1983 if let ModelSource::Proprietary { endpoint, .. } = &mut schema.source {
1984 *endpoint = api_base.trim_end_matches('/').to_string();
1985 schema.available = available;
1986 }
1987 }
1988 }
1989
1990 pub(crate) fn work_context_serves(&self, model: &str) -> bool {
1996 self.get_by_id_or_name(model)
1997 .is_some_and(|schema| !is_work_context_row(schema) || schema.available)
1998 }
1999
2000 pub fn credential_state_is_unknown(&self, env_var: &str) -> bool {
2003 self.credential_state_unknown.contains(env_var)
2004 }
2005
2006 fn record_codex_lookup(&mut self, lookup: Result<bool, String>) {
2011 match lookup {
2012 Ok(present) => remember_codex_sign_in(present),
2013 Err(_) => {
2014 remember_codex_sign_in(false);
2015 self.codex_state_unknown = true;
2016 }
2017 }
2018 }
2019
2020 pub fn row_credential_state_unknown(&self, schema: &ModelSchema) -> bool {
2025 match &schema.source {
2026 ModelSource::RemoteApi {
2027 protocol: crate::schema::ApiProtocol::OpenRouter,
2028 ..
2029 } => self.openrouter_state_unknown,
2030 ModelSource::RemoteApi { api_key_env, .. } => {
2031 self.credential_state_unknown.contains(api_key_env)
2032 }
2033 ModelSource::Proprietary { auth, .. } => match auth {
2034 ProprietaryAuth::ApiKeyEnv { env_var }
2035 | ProprietaryAuth::BearerTokenEnv { env_var } => {
2036 self.credential_state_unknown.contains(env_var)
2037 }
2038 ProprietaryAuth::ChatGptSubscription {} => self.codex_state_unknown,
2039 ProprietaryAuth::OAuth2Pkce { .. } => false,
2040 },
2041 _ => false,
2042 }
2043 }
2044
2045 fn refresh_availability_with(
2046 &mut self,
2047 parslee_oauth_available: bool,
2048 clear_parslee_observations: bool,
2049 authoritative_credentials: bool,
2050 ) {
2051 let models_dir = self.models_dir.clone();
2055 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2060 let mlx_vlm_cli_present = crate::backend::mlx_vlm_cli::is_available();
2061 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2062 #[allow(unused_variables)]
2063 let mlx_vlm_cli_present = false;
2064 if clear_parslee_observations {
2069 crate::openrouter::clear_gateway_unconfigured();
2070 crate::parslee_credential::clear_credential_rejected();
2074 }
2075
2076 let mut credential_envs: std::collections::BTreeSet<String> = Default::default();
2094 let mut needs_openrouter = false;
2095 let mut needs_codex = false;
2096 for m in self.models.values() {
2097 if crate::typesafe::is_direct(m) {
2098 continue; }
2100 match &m.source {
2101 ModelSource::RemoteApi {
2102 protocol: crate::schema::ApiProtocol::OpenRouter,
2103 ..
2104 } => needs_openrouter = true,
2105 ModelSource::RemoteApi { api_key_env, .. } => {
2106 credential_envs.insert(api_key_env.clone());
2107 }
2108 ModelSource::Proprietary { auth, .. } => match auth {
2109 ProprietaryAuth::ApiKeyEnv { env_var }
2110 | ProprietaryAuth::BearerTokenEnv { env_var } => {
2111 credential_envs.insert(env_var.clone());
2112 }
2113 ProprietaryAuth::ChatGptSubscription {} => needs_codex = true,
2114 ProprietaryAuth::OAuth2Pkce { .. } => {}
2115 },
2116 _ => {}
2117 }
2118 }
2119 let mut credential_unknown: std::collections::HashSet<String> =
2120 std::collections::HashSet::new();
2121 self.codex_state_unknown = false;
2128 if authoritative_credentials && needs_codex {
2129 self.record_codex_lookup(car_auth::codex_credential_exists());
2130 }
2131 let credential_available: std::collections::HashMap<String, bool> = credential_envs
2132 .into_iter()
2133 .map(|env| {
2134 let available = if authoritative_credentials {
2135 match credential_presence(&env) {
2139 CredentialPresence::Present => true,
2140 CredentialPresence::Absent => false,
2141 CredentialPresence::Unknown => {
2142 credential_unknown.insert(env.clone());
2143 false
2144 }
2145 }
2146 } else {
2147 environment_credential_available(&env)
2148 };
2149 (env, available)
2150 })
2151 .collect();
2152 self.credential_state_unknown = credential_unknown;
2153 let stored_available: std::collections::HashMap<String, bool> = self
2154 .models
2155 .values()
2156 .filter(|row| crate::typesafe::is_direct(row))
2157 .filter_map(|row| crate::typesafe::key_name(row).ok())
2158 .collect::<std::collections::BTreeSet<_>>()
2159 .into_iter()
2160 .map(|key| {
2161 let available = if authoritative_credentials {
2162 match stored_credential_presence(key) {
2163 CredentialPresence::Present => true,
2164 CredentialPresence::Absent => false,
2165 CredentialPresence::Unknown => {
2166 self.credential_state_unknown.insert(key.to_owned());
2167 false
2168 }
2169 }
2170 } else {
2171 false
2172 };
2173 (key.to_owned(), available)
2174 })
2175 .collect();
2176 self.openrouter_state_unknown = false;
2179 let openrouter_available = needs_openrouter
2180 && if authoritative_credentials {
2181 let (source, unknown) = crate::openrouter::refresh_credential_source_with_state();
2182 self.openrouter_state_unknown = unknown;
2183 source.is_some()
2184 } else {
2185 crate::openrouter::credential_source().is_some()
2186 };
2187
2188 for m in self.models.values_mut() {
2189 let direct = crate::typesafe::is_direct(m);
2190 match &m.source {
2191 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2192 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2201 {
2202 let needs_mlx_vlm = m.tags.iter().any(|t| t == "requires-mlx-vlm");
2209
2210 let in_process_loader_present = !mlx_row_needs_in_process_loader(m)
2218 || crate::backend::local::in_process_loader_available();
2219
2220 m.available = if needs_mlx_vlm {
2221 mlx_vlm_cli_present
2222 } else if m.tags.contains(&"speech".to_string()) {
2223 speech_mlx_available()
2224 } else {
2225 let mlx_dir = models_dir.join(&m.name);
2234 in_process_loader_present
2235 && (mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty())
2236 };
2237 }
2238 #[cfg(not(all(
2239 target_os = "macos",
2240 target_arch = "aarch64",
2241 not(car_skip_mlx)
2242 )))]
2243 {
2244 let _ = hf_repo; m.available = false;
2246 }
2247 }
2248 ModelSource::Local {
2249 hf_repo: local_repo,
2250 ..
2251 } => {
2252 let local_path = models_dir.join(&m.name).join("model.gguf");
2253 #[cfg(not(all(
2268 target_os = "macos",
2269 target_arch = "aarch64",
2270 not(car_skip_mlx)
2271 )))]
2272 {
2273 m.available = local_path.exists() || !local_repo.is_empty();
2274 }
2275 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2280 {
2281 let _ = local_repo;
2282 m.available = local_path.exists();
2283 }
2284 }
2285 ModelSource::WhisperCpp { .. } => {
2286 m.available = true;
2291 }
2292 ModelSource::WindowsSpeech {} => {
2293 #[cfg(target_os = "windows")]
2296 {
2297 m.available = true;
2298 }
2299 #[cfg(not(target_os = "windows"))]
2300 {
2301 m.available = false;
2302 }
2303 }
2304 ModelSource::RemoteApi {
2305 protocol: crate::schema::ApiProtocol::OpenRouter,
2306 ..
2307 } => {
2308 m.available = openrouter_available;
2309 }
2310 ModelSource::RemoteApi { api_key_env, .. } => {
2311 m.available = credential_available
2314 .get(api_key_env)
2315 .copied()
2316 .unwrap_or(false);
2317 }
2318 ModelSource::CodexCli { .. } => {
2319 m.available = crate::backend::codex_cli::is_available();
2320 }
2321 ModelSource::Ollama { .. } => {
2322 m.available = true;
2324 }
2325 ModelSource::VllmMlx { .. } => {
2326 m.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || m.available;
2330 }
2331 ModelSource::Proprietary { provider, auth, .. } => {
2332 m.available = if direct {
2333 crate::typesafe::key_name(m)
2334 .ok()
2335 .and_then(|key| stored_available.get(key))
2336 .copied()
2337 .unwrap_or(false)
2338 } else {
2339 proprietary_auth_available(
2340 &m.id,
2341 &m.provider,
2342 provider,
2343 auth,
2344 parslee_oauth_available,
2345 &credential_available,
2346 )
2347 };
2348 }
2349 ModelSource::AppleFoundationModels { .. } => {
2350 #[cfg(any(
2357 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2358 all(target_os = "ios", target_arch = "aarch64")
2359 ))]
2360 {
2361 m.available = crate::backend::foundation_models::is_available();
2362 if crate::backend::foundation_models::supports_parallel_tool_calls()
2375 && !m.capabilities.contains(&ModelCapability::MultiToolCall)
2376 {
2377 m.capabilities.push(ModelCapability::MultiToolCall);
2378 }
2379 if crate::backend::foundation_models::supports_vision()
2388 && !m.capabilities.contains(&ModelCapability::Vision)
2389 {
2390 m.capabilities.push(ModelCapability::Vision);
2391 }
2392 if let Some(window) = crate::backend::foundation_models::context_size() {
2400 m.context_length = window as usize;
2401 }
2402 }
2403 #[cfg(not(any(
2404 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2405 all(target_os = "ios", target_arch = "aarch64")
2406 )))]
2407 {
2408 m.available = false;
2409 }
2410 }
2411 ModelSource::Delegated { .. } => {
2412 m.available = crate::runner::current_inference_runner().is_some();
2417 }
2418 }
2419 m.weights_ready = physical_weights_ready(m, &models_dir);
2420 }
2421 }
2422
2423 pub fn save_user_config(&self) -> Result<(), InferenceError> {
2425 let mut user_models: Vec<ModelSchema> = self
2426 .user_config_ids
2427 .iter()
2428 .filter_map(|id| self.models.get(id))
2429 .cloned()
2430 .map(|mut model| {
2431 model.mark_user_registered();
2434 model
2435 })
2436 .collect();
2437 user_models.sort_by(|a, b| a.id.cmp(&b.id));
2438
2439 for model in &user_models {
2440 crate::catalog_identity::row_digest(model).map_err(|error| {
2441 InferenceError::InferenceFailed(format!(
2442 "refuse to persist model without canonical catalog identity: {error}"
2443 ))
2444 })?;
2445 }
2446
2447 let json = serde_json::to_string_pretty(&user_models)
2448 .map_err(|e| InferenceError::InferenceFailed(format!("serialize: {e}")))?;
2449 std::fs::write(&self.user_config_path, json)?;
2450 Ok(())
2451 }
2452
2453 pub fn load_user_config(&mut self) -> Result<(), InferenceError> {
2455 if !self.user_config_path.exists() {
2456 return Ok(());
2457 }
2458
2459 let json = std::fs::read_to_string(&self.user_config_path)?;
2460 let models: Vec<ModelSchema> = serde_json::from_str(&json)
2461 .map_err(|e| InferenceError::InferenceFailed(format!("parse models.json: {e}")))?;
2462
2463 for m in models {
2464 self.register_user_model(m);
2467 }
2468 Ok(())
2469 }
2470
2471 pub fn models_dir(&self) -> &Path {
2473 &self.models_dir
2474 }
2475
2476 pub async fn ensure_drafter(
2485 &self,
2486 schema: &ModelSchema,
2487 ) -> Result<Option<PathBuf>, InferenceError> {
2488 let ModelSource::ManagedVllmMlx {
2489 draft_hf_repo: Some(repo),
2490 ..
2491 } = &schema.source
2492 else {
2493 return Ok(None);
2494 };
2495 let dir = drafter_dir(&self.models_dir, schema);
2496 if !mlx_layout_complete(false, &dir) {
2497 if never_download_in_scope() {
2498 return Err(refuse_download(schema));
2499 }
2500 std::fs::create_dir_all(&dir)?;
2501 info!(model = %schema.name, repo = %repo, "downloading speculative-decoding drafter");
2502 download_repo_snapshot(repo, &dir, &self.ambient_progress).await?;
2503 if !mlx_layout_complete(false, &dir) {
2504 return Err(InferenceError::DownloadFailed(format!(
2505 "{repo}: drafter fetched but has no config.json or weights"
2506 )));
2507 }
2508 }
2509 Ok(Some(dir))
2510 }
2511
2512 pub fn ready_without_download(&self, id: &str) -> Option<bool> {
2528 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2529 Some(self.weights_ready_without_download(id)? && self.drafter_ready(schema))
2530 }
2531
2532 pub fn acquires_without_download(&self, id: &str) -> Option<bool> {
2540 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2541 match &schema.source {
2542 ModelSource::Local { .. }
2543 | ModelSource::Mlx { .. }
2544 | ModelSource::ManagedVllmMlx { .. } => {
2545 Some(self.reusable_local_dir(schema).is_some() && self.drafter_ready(schema))
2546 }
2547 _ => self.ready_without_download(id),
2548 }
2549 }
2550
2551 fn drafter_ready(&self, schema: &ModelSchema) -> bool {
2554 match &schema.source {
2555 ModelSource::ManagedVllmMlx {
2556 draft_hf_repo: Some(_),
2557 ..
2558 } => mlx_layout_complete(false, &drafter_dir(&self.models_dir, schema)),
2559 _ => true,
2560 }
2561 }
2562
2563 fn weights_ready_without_download(&self, id: &str) -> Option<bool> {
2566 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2567 Some(match &schema.source {
2568 ModelSource::Local { .. } => {
2569 let model_dir = self.models_dir.join(&schema.name);
2570 crate::download::cache_file_usable(&model_dir.join("model.gguf"))
2571 && crate::download::cache_file_usable(&model_dir.join("tokenizer.json"))
2572 }
2573 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2574 let managed_dir = self.models_dir.join(&schema.name);
2575 let managed_ready = mlx_snapshot_complete(schema, &managed_dir)
2576 && crate::download::cache_file_usable(&managed_dir.join("tokenizer.json"));
2577 let snapshot_ready =
2578 latest_huggingface_repo_snapshot(hf_repo).is_some_and(|snapshot| {
2579 mlx_snapshot_complete(schema, &snapshot)
2580 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2581 });
2582 managed_ready || snapshot_ready
2583 }
2584 ModelSource::WindowsSpeech {} => true, ModelSource::WhisperCpp { model } => {
2586 car_whisper::model_cached(model)
2590 }
2591 ModelSource::RemoteApi { .. }
2592 | ModelSource::CodexCli { .. }
2593 | ModelSource::Ollama { .. }
2594 | ModelSource::VllmMlx { .. }
2595 | ModelSource::AppleFoundationModels { .. }
2596 | ModelSource::Proprietary { .. }
2597 | ModelSource::Delegated { .. } => true,
2598 })
2599 }
2600
2601 pub fn existing_local_artifact(&self, id: &str) -> Option<PathBuf> {
2605 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2606 let managed = self.models_dir.join(&schema.name);
2607 if std::fs::symlink_metadata(&managed).is_ok()
2608 && self.weights_ready_without_download(&schema.id) == Some(true)
2609 {
2610 return Some(managed);
2611 }
2612 match &schema.source {
2613 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2614 latest_huggingface_repo_snapshot(hf_repo).filter(|snapshot| {
2615 mlx_snapshot_complete(schema, snapshot)
2616 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2617 })
2618 }
2619 _ => None,
2620 }
2621 }
2622
2623 fn load_builtin_catalog(&mut self) {
2625 for schema in builtin_catalog() {
2626 let id = schema.id.clone();
2627 if self.register_project_model(schema) {
2628 self.builtin_model_ids.insert(id);
2629 }
2630 }
2631 }
2632}
2633
2634fn synthesize_local_schema(name: &str, dir: &Path) -> Option<ModelSchema> {
2644 let lower = name.to_ascii_lowercase();
2645
2646 const NON_TEXT_HINTS: &[&str] = &[
2649 "vad",
2650 "whisper",
2651 "parakeet",
2652 "kokoro",
2653 "tts",
2654 "stt",
2655 "flux",
2656 "ltx",
2657 "yume",
2658 "sd-",
2659 "stable-diffusion",
2660 "wan",
2661 "mochi",
2662 "sana",
2663 "diffusion",
2664 ];
2665 if NON_TEXT_HINTS.iter().any(|h| lower.contains(h)) {
2666 return None;
2667 }
2668
2669 let capabilities: Vec<ModelCapability> =
2672 if lower.contains("embedding") || lower.contains("embed") {
2673 vec![ModelCapability::Embed]
2674 } else if lower.contains("reranker") || lower.contains("rerank") {
2675 vec![ModelCapability::Rerank]
2676 } else {
2677 vec![
2678 ModelCapability::Generate,
2679 ModelCapability::Code,
2680 ModelCapability::Reasoning,
2681 ]
2682 };
2683
2684 let config_path = dir.join("config.json");
2686 let has_safetensors =
2687 dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists();
2688
2689 let (source, context_length, quantization) = if config_path.exists() && has_safetensors {
2690 let cfg: serde_json::Value = std::fs::read_to_string(&config_path)
2692 .ok()
2693 .and_then(|s| serde_json::from_str(&s).ok())?;
2694 let model_type = cfg
2695 .get("model_type")
2696 .and_then(|v| v.as_str())
2697 .unwrap_or("")
2698 .to_ascii_lowercase();
2699 const KNOWN_LLM_TYPES: &[&str] = &[
2706 "qwen",
2707 "qwen2",
2708 "qwen3",
2709 "qwen3_moe",
2710 "llama",
2711 "mistral",
2712 "mixtral",
2713 "gemma",
2714 "gemma2",
2715 "gemma3",
2716 "gemma4_unified",
2717 "gemma4_unified_text",
2718 "phi",
2719 "phi3",
2720 "phimoe",
2721 "starcoder2",
2722 "deepseek",
2723 "deepseek_v2",
2724 "internlm2",
2725 "cohere",
2726 "olmo",
2727 ];
2728 let recognized = KNOWN_LLM_TYPES.iter().any(|t| model_type == *t)
2729 || crate::backend::local::has_native_backend(&model_type);
2730 if !recognized {
2731 return None;
2732 }
2733 let ctx = cfg
2734 .get("max_position_embeddings")
2735 .and_then(|v| v.as_u64())
2736 .unwrap_or(32_768) as usize;
2737 let quant = cfg
2743 .get("quantization")
2744 .filter(|q| q.is_object())
2745 .and_then(|q| {
2746 let bits = q
2747 .get("bits")
2748 .and_then(|b| b.as_u64())
2749 .and_then(|b| u8::try_from(b).ok());
2750 let group_size = q
2751 .get("group_size")
2752 .and_then(|g| g.as_u64())
2753 .and_then(|g| u32::try_from(g).ok());
2754 let mode = q.get("mode").and_then(|m| m.as_str());
2755 crate::schema::Quantization::from_mlx_config(bits, group_size, mode)
2756 });
2757 (
2758 serde_json::json!({ "type": "mlx", "hf_repo": "" }),
2759 ctx,
2760 quant,
2761 )
2762 } else {
2763 let gguf = std::fs::read_dir(dir).ok().and_then(|rd| {
2764 rd.flatten().map(|e| e.path()).find(|p| {
2765 p.extension()
2766 .and_then(|x| x.to_str())
2767 .is_some_and(|x| x.eq_ignore_ascii_case("gguf"))
2768 })
2769 })?;
2770 let filename = gguf
2772 .file_name()
2773 .and_then(|n| n.to_str())
2774 .unwrap_or("model.gguf")
2775 .to_string();
2776 let quant = crate::schema::Quantization::from_gguf_filename(&filename);
2780 (
2781 serde_json::json!({
2782 "type": "local",
2783 "hf_repo": "",
2784 "hf_filename": filename,
2785 "tokenizer_repo": "",
2786 }),
2787 4_096,
2788 quant,
2789 )
2790 };
2791
2792 let id = format!("local/{}", lower.replace(['/', ' '], "-"));
2793 serde_json::from_value(serde_json::json!({
2794 "id": id,
2795 "name": name,
2796 "provider": "local",
2797 "family": "local",
2798 "capabilities": capabilities,
2799 "context_length": context_length,
2800 "quantization": quantization,
2801 "source": source,
2802 "tags": ["auto-discovered"],
2803 "trust_tier": "community",
2804 }))
2805 .ok()
2806}
2807
2808#[allow(dead_code)]
2827fn speech_mlx_available() -> bool {
2828 let runtime_root = speech_runtime_root();
2832 crate::managed_venv::venv_program(&runtime_root, "mlx_audio.stt.generate").exists()
2833 || crate::managed_venv::venv_program(&runtime_root, "mlx_audio.tts.generate").exists()
2834}
2835
2836#[allow(dead_code)]
2838fn speech_runtime_root() -> PathBuf {
2839 if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
2840 if !path.trim().is_empty() {
2841 return PathBuf::from(path);
2842 }
2843 }
2844 std::env::var_os("HOME")
2845 .or_else(|| std::env::var_os("USERPROFILE"))
2846 .map(PathBuf::from)
2847 .unwrap_or_else(|| PathBuf::from("."))
2848 .join(".car")
2849 .join("speech-runtime")
2850}
2851
2852#[derive(Debug, Clone, Serialize, Deserialize)]
2854pub struct ModelInfo {
2855 pub id: String,
2856 pub name: String,
2857 pub provider: String,
2858 pub capabilities: Vec<ModelCapability>,
2859 pub param_count: String,
2860 pub size_mb: u64,
2861 pub context_length: usize,
2862 pub available: bool,
2863 pub is_local: bool,
2864 #[serde(default)]
2868 pub operator_managed_external_runtime: bool,
2869 #[serde(default)]
2877 pub weights_ready: bool,
2878 #[serde(default)]
2891 pub downloads_weights: bool,
2892 #[serde(default)]
2896 pub max_output_tokens: Option<usize>,
2897 #[serde(default)]
2901 pub public_benchmarks: Vec<crate::schema::BenchmarkScore>,
2902 #[serde(default)]
2917 pub cost: crate::schema::CostModel,
2918 #[serde(default = "default_true")]
2919 pub car_enabled: bool,
2920 #[serde(default)]
2921 pub can_remove: bool,
2922 #[serde(default)]
2923 pub in_use: bool,
2924 #[serde(default)]
2925 pub management_evidence: Option<String>,
2926 #[serde(default)]
2937 pub fit: crate::recommend::ModelFitStatus,
2938 #[serde(default)]
2941 pub estimated_peak_mb: Option<u64>,
2942 #[serde(default = "default_true")]
2951 pub platform_compatible: bool,
2952 #[serde(default)]
2956 pub deprecated: bool,
2957 #[serde(default)]
2995 pub credential_required: Option<String>,
2996 #[serde(default)]
2998 pub unavailable_reason: Option<String>,
2999 #[serde(default)]
3005 pub family: Option<String>,
3006 #[serde(default)]
3010 pub version: Option<String>,
3011 #[serde(default)]
3017 pub source_kind: String,
3018}
3019
3020pub fn model_unavailable_reason(info: &ModelInfo) -> Option<&'static str> {
3023 (!info.available && info.provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER))
3024 .then_some(crate::schema::OPENAI_CODEX_SIGN_IN_HINT)
3025}
3026
3027fn default_true() -> bool {
3028 true
3029}
3030
3031impl ModelInfo {
3032 pub fn with_fit(mut self, fit: crate::recommend::ModelFit) -> Self {
3036 self.fit = fit.fit;
3037 self.estimated_peak_mb = fit.estimated_peak_mb;
3038 self.platform_compatible = fit.platform_compatible;
3039 self
3040 }
3041}
3042
3043impl From<&ModelSchema> for ModelInfo {
3044 fn from(s: &ModelSchema) -> Self {
3045 let mut info = ModelInfo {
3046 id: s.id.clone(),
3047 name: s.name.clone(),
3048 provider: s.provider.clone(),
3049 capabilities: s.capabilities.clone(),
3050 param_count: s.param_count.clone(),
3051 size_mb: s.size_mb(),
3052 context_length: s.context_length,
3053 available: s.available_now(),
3054 is_local: s.is_local(),
3055 operator_managed_external_runtime: matches!(s.source, ModelSource::VllmMlx { .. }),
3056 weights_ready: s.weights_ready,
3057 downloads_weights: s.downloads_weights(),
3058 max_output_tokens: s.max_output_tokens,
3059 public_benchmarks: s.public_benchmarks.clone(),
3060 cost: s.cost.clone(),
3065 car_enabled: true,
3066 can_remove: false,
3067 in_use: false,
3068 management_evidence: None,
3069 fit: crate::recommend::ModelFitStatus::Unknown,
3074 estimated_peak_mb: None,
3075 platform_compatible: true,
3076 credential_required: s.credential_required(),
3077 unavailable_reason: None,
3078 deprecated: s.deprecated,
3079 family: s.is_local().then(|| s.family.clone()),
3082 version: s.is_local().then(|| s.version.clone()),
3083 source_kind: s.source.kind().to_string(),
3084 };
3085 info.unavailable_reason = model_unavailable_reason(&info).map(str::to_owned);
3086 info
3087 }
3088}
3089
3090fn emit_file(sink: &ProgressSink, name: &str, index: u32, size_mb: u64) {
3095 sink.emit(DownloadEvent::FileStarted {
3096 filename: name.to_string(),
3097 index,
3098 total_files: 0,
3099 size_mb,
3100 });
3101}
3102
3103async fn download_repo_snapshot(
3112 repo: &str,
3113 model_dir: &Path,
3114 sink: &ProgressSink,
3115) -> Result<(), InferenceError> {
3116 refuse_fetch_when_never_download(repo)?;
3117 #[derive(serde::Deserialize)]
3118 struct RepoInfo {
3119 siblings: Vec<Sibling>,
3120 }
3121 #[derive(serde::Deserialize)]
3122 struct Sibling {
3123 rfilename: String,
3124 }
3125 let url = format!("{}/api/models/{repo}", crate::hf_cache::endpoint());
3126 let info: RepoInfo = crate::tls_client::model_download_client()
3131 .get(&url)
3132 .send()
3133 .await
3134 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
3135 .error_for_status()
3136 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
3137 .json()
3138 .await
3139 .map_err(|e| InferenceError::DownloadFailed(format!("parse {repo} file list: {e}")))?;
3140
3141 let files: Vec<String> = info
3142 .siblings
3143 .into_iter()
3144 .map(|s| s.rfilename)
3145 .filter(|f| !f.starts_with('.') && !f.to_ascii_lowercase().ends_with(".md"))
3146 .collect();
3147 if files.is_empty() {
3148 return Err(InferenceError::DownloadFailed(format!(
3149 "{repo}: repo lists no downloadable files"
3150 )));
3151 }
3152
3153 let total = files.len() as u32;
3154 for (i, fname) in files.iter().enumerate() {
3155 let dest = model_dir.join(fname);
3156 if crate::download::cache_file_usable(&dest) {
3157 continue;
3158 }
3159 if let Some(parent) = dest.parent() {
3160 std::fs::create_dir_all(parent)?;
3161 }
3162 sink.emit(DownloadEvent::FileStarted {
3163 filename: fname.clone(),
3164 index: (i + 1) as u32,
3165 total_files: total,
3166 size_mb: 0,
3167 });
3168 download_file(repo, fname, &dest).await?;
3169 sink.emit(DownloadEvent::FileCompleted {
3170 filename: fname.clone(),
3171 });
3172 }
3173 Ok(())
3174}
3175
3176pub(crate) const TOKENIZER_FILENAMES: &[&str] = &[
3187 "tokenizer.json",
3188 "vocab.json",
3189 "merges.txt",
3190 "tokenizer.model",
3191 "tokenizer.vocab",
3192 "vocab.txt",
3193];
3194
3195async fn download_tokenizer_assets(hf_repo: &str, model_dir: &Path, sink: &ProgressSink) {
3215 if TOKENIZER_FILENAMES
3217 .iter()
3218 .any(|f| crate::download::cache_file_usable(&model_dir.join(f)))
3219 {
3220 return;
3221 }
3222 emit_file(sink, "tokenizer", 0, 0);
3223 let mut fetched: Vec<&str> = Vec::new();
3224 for name in TOKENIZER_FILENAMES {
3225 let dest = model_dir.join(name);
3226 if crate::download::cache_file_usable(&dest) {
3227 continue;
3228 }
3229 if download_file(hf_repo, name, &dest).await.is_ok() {
3230 fetched.push(name);
3231 }
3232 }
3233 if fetched.is_empty() {
3234 tracing::debug!(
3236 repo = %hf_repo,
3237 "no tokenizer assets in this repo; continuing (the backend may not need one)"
3238 );
3239 } else {
3240 tracing::debug!(repo = %hf_repo, files = ?fetched, "fetched tokenizer assets");
3241 }
3242}
3243
3244async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
3245 if crate::download::cache_file_usable(dest) {
3249 return Ok(());
3250 }
3251 refuse_fetch_when_never_download(&format!("{repo}/{filename}"))?;
3252
3253 let api = crate::hf_cache::api()
3254 .build()
3255 .map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
3256
3257 let already_cached = hf_hub::Cache::new(crate::hf_cache::hub_dir())
3260 .repo(hf_hub::Repo::model(repo.to_string()))
3261 .get(filename)
3262 .is_some();
3263 let repo_id = repo;
3264 let repo = api.model(repo.to_string());
3265 let path = repo
3266 .get(filename)
3267 .await
3268 .map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
3269 if !already_cached {
3270 crate::retire::note_fetched(repo_id, filename, crate::retire::snapshot_revision(&path));
3271 }
3272
3273 install_fetched_file(&path, dest)
3274}
3275
3276fn install_fetched_file(src: &Path, dest: &Path) -> Result<(), InferenceError> {
3278 if crate::download::cache_file_usable(dest) {
3279 return Ok(());
3280 }
3281
3282 match std::fs::symlink_metadata(dest) {
3283 Ok(_) => std::fs::remove_file(dest).map_err(|e| {
3284 InferenceError::DownloadFailed(format!(
3285 "remove unusable destination {}: {e}",
3286 dest.display()
3287 ))
3288 })?,
3289 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3290 Err(error) => {
3291 return Err(InferenceError::DownloadFailed(format!(
3292 "inspect destination {}: {error}",
3293 dest.display()
3294 )));
3295 }
3296 }
3297
3298 #[cfg(unix)]
3302 {
3303 if std::os::unix::fs::symlink(src, dest).is_ok() {
3304 return Ok(());
3305 }
3306 }
3307
3308 static INSTALL_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3309 let sequence = INSTALL_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3310 let file_name = dest
3311 .file_name()
3312 .and_then(|name| name.to_str())
3313 .unwrap_or("download");
3314 let temp = dest.with_file_name(format!(
3315 ".{file_name}.car-install-{}-{sequence}.tmp",
3316 std::process::id()
3317 ));
3318 let _ = std::fs::remove_file(&temp);
3319 std::fs::copy(src, &temp).map_err(|error| {
3320 InferenceError::DownloadFailed(format!(
3321 "copy to temporary destination {}: {error}",
3322 temp.display()
3323 ))
3324 })?;
3325 if let Err(error) = std::fs::rename(&temp, dest) {
3326 let _ = std::fs::remove_file(&temp);
3327 return Err(InferenceError::DownloadFailed(format!(
3328 "publish downloaded file at {}: {error}",
3329 dest.display()
3330 )));
3331 }
3332 Ok(())
3333}
3334
3335pub(crate) const FLUX_AUXILIARY: (&str, &str, &str, &str) = (
3340 "mlx-community/Flux-1.lite-8B-MLX-Q4",
3341 "Flux-1.lite-8B-MLX-Q4",
3342 "Freepik/flux.1-lite-8B",
3343 "tokenizer_2/tokenizer.json",
3344);
3345
3346pub(crate) const LTX_TEXT_ENCODER: &str = "mlx-community/gemma-3-12b-it-4bit";
3352
3353pub(crate) fn uses_ltx_text_encoder(schema: &ModelSchema) -> bool {
3356 matches!(schema.source, ModelSource::Mlx { .. })
3357 && schema.has_capability(ModelCapability::VideoGeneration)
3358}
3359
3360pub(crate) fn uses_flux_auxiliary(model_name: &str, hf_repo: &str) -> bool {
3361 hf_repo == FLUX_AUXILIARY.0 || model_name == FLUX_AUXILIARY.1
3362}
3363
3364fn auxiliary_mlx_files_missing(model_name: &str, hf_repo: &str, model_dir: &Path) -> bool {
3365 uses_flux_auxiliary(model_name, hf_repo)
3366 && !crate::download::cache_file_usable(
3367 &model_dir.join("tokenizer_2").join("tokenizer.json"),
3368 )
3369}
3370
3371fn chat_template_missing(model_dir: &Path) -> bool {
3381 let tok_cfg = model_dir.join("tokenizer_config.json");
3382 if !crate::download::cache_file_usable(&tok_cfg)
3383 || crate::download::cache_file_usable(&model_dir.join("chat_template.jinja"))
3384 {
3385 return false;
3386 }
3387 std::fs::read_to_string(&tok_cfg)
3388 .ok()
3389 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
3390 .is_some_and(|cfg| cfg.get("chat_template").is_none())
3391}
3392
3393async fn ensure_auxiliary_mlx_files(
3394 model_name: &str,
3395 hf_repo: &str,
3396 model_dir: &Path,
3397) -> Result<(), InferenceError> {
3398 if chat_template_missing(model_dir) {
3402 if let Err(error) = download_file(
3403 hf_repo,
3404 "chat_template.jinja",
3405 &model_dir.join("chat_template.jinja"),
3406 )
3407 .await
3408 {
3409 tracing::debug!(repo = %hf_repo, %error, "no chat_template.jinja in this repo");
3410 }
3411 }
3412 if auxiliary_mlx_files_missing(model_name, hf_repo, model_dir) {
3413 let t5_tokenizer_path = model_dir.join("tokenizer_2").join("tokenizer.json");
3414 std::fs::create_dir_all(
3415 t5_tokenizer_path
3416 .parent()
3417 .ok_or_else(|| InferenceError::InferenceFailed("invalid tokenizer path".into()))?,
3418 )?;
3419 info!(
3420 path = %t5_tokenizer_path.display(),
3421 "downloading missing Flux tokenizer_2/tokenizer.json from base model"
3422 );
3423 download_file(FLUX_AUXILIARY.2, FLUX_AUXILIARY.3, &t5_tokenizer_path).await?;
3424 }
3425 Ok(())
3426}
3427
3428fn mlx_auxiliary_ready_without_download(model_name: &str, model_dir: &Path) -> bool {
3429 if model_name == "Flux-1.lite-8B-MLX-Q4" {
3430 return crate::download::cache_file_usable(
3431 &model_dir.join("tokenizer_2").join("tokenizer.json"),
3432 );
3433 }
3434 true
3435}
3436
3437fn physical_weights_ready(schema: &ModelSchema, models_dir: &Path) -> bool {
3440 physical_weights_ready_with_huggingface_hub(schema, models_dir, None)
3441}
3442
3443pub(crate) fn physical_weights_ready_with_huggingface_hub(
3444 schema: &ModelSchema,
3445 models_dir: &Path,
3446 huggingface_hub_root: Option<&Path>,
3447) -> bool {
3448 match &schema.source {
3449 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
3450 let managed_dir = models_dir.join(&schema.name);
3451 if mlx_snapshot_complete(schema, &managed_dir) {
3452 return true;
3453 }
3454 let shared_snapshot = match huggingface_hub_root {
3455 Some(root) => latest_huggingface_repo_snapshot_in(&crate::hf_cache::repo_dir_in(
3456 root, hf_repo,
3457 )),
3458 None => latest_huggingface_repo_snapshot(hf_repo),
3459 };
3460 shared_snapshot
3461 .as_deref()
3462 .is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3463 }
3464 ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
3465 ModelSource::Local { .. } => {
3466 crate::download::cache_file_usable(&models_dir.join(&schema.name).join("model.gguf"))
3467 }
3468 ModelSource::WindowsSpeech {}
3472 | ModelSource::AppleFoundationModels { .. }
3473 | ModelSource::VllmMlx { .. }
3474 | ModelSource::Ollama { .. }
3475 | ModelSource::RemoteApi { .. }
3476 | ModelSource::CodexCli { .. }
3477 | ModelSource::Proprietary { .. }
3478 | ModelSource::Delegated { .. } => true,
3479 }
3480}
3481
3482#[cfg(test)]
3483fn mlx_weights_ready_at(
3484 schema: &ModelSchema,
3485 managed_dir: &Path,
3486 shared_snapshot: Option<&Path>,
3487) -> bool {
3488 mlx_snapshot_complete(schema, managed_dir)
3489 || shared_snapshot.is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3490}
3491
3492fn mlx_snapshot_complete(schema: &ModelSchema, dir: &Path) -> bool {
3499 mlx_layout_complete(mlx_schema_is_diffusers(schema), dir)
3500 && mlx_auxiliary_ready_without_download(&schema.name, dir)
3501}
3502
3503fn mlx_schema_is_diffusers(schema: &ModelSchema) -> bool {
3507 schema.capabilities.iter().any(|capability| {
3508 matches!(
3509 capability,
3510 ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
3511 )
3512 })
3513}
3514
3515tokio::task_local! {
3516 static NEVER_DOWNLOAD: ();
3518}
3519
3520pub(crate) fn never_download_scope<F: std::future::Future>(
3538 never: bool,
3539 future: F,
3540) -> impl std::future::Future<Output = F::Output> {
3541 let future = Box::pin(future);
3542 async move {
3543 if never {
3544 NEVER_DOWNLOAD.scope((), future).await
3545 } else {
3546 future.await
3547 }
3548 }
3549}
3550
3551pub(crate) fn never_download_sync_scope<R>(never: bool, f: impl FnOnce() -> R) -> R {
3555 if never {
3556 NEVER_DOWNLOAD.sync_scope((), f)
3557 } else {
3558 f()
3559 }
3560}
3561
3562pub(crate) fn never_download_in_scope() -> bool {
3564 NEVER_DOWNLOAD.try_with(|_| ()).is_ok()
3565}
3566
3567#[cfg(test)]
3570pub(crate) static REFUSED_DOWNLOADS: std::sync::atomic::AtomicUsize =
3571 std::sync::atomic::AtomicUsize::new(0);
3572
3573pub(crate) fn not_installed_error(schema: &ModelSchema) -> InferenceError {
3576 InferenceError::NoEligibleModel {
3577 excluded_models: format!(
3578 "{name} is not installed, and this request never downloads a model. \
3579 Install it with `car models pull {name}`, or connect a provider's API key \
3580 so a model that is ready can be used",
3581 name = schema.name
3582 ),
3583 }
3584}
3585
3586fn refuse_download(schema: &ModelSchema) -> InferenceError {
3588 #[cfg(test)]
3589 REFUSED_DOWNLOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3590 tracing::info!(model = %schema.name, "refused a model download for a never-download request");
3591 not_installed_error(schema)
3592}
3593
3594pub(crate) fn refuse_fetch_when_never_download(what: &str) -> Result<(), InferenceError> {
3597 if !never_download_in_scope() {
3598 return Ok(());
3599 }
3600 #[cfg(test)]
3601 REFUSED_DOWNLOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3602 Err(InferenceError::NoEligibleModel {
3603 excluded_models: format!(
3604 "{what} would have to be downloaded, and this request never downloads a model"
3605 ),
3606 })
3607}
3608
3609fn drafter_dir(models_dir: &Path, schema: &ModelSchema) -> PathBuf {
3611 models_dir.join(&schema.name).join("mtp-drafter")
3612}
3613
3614fn mlx_layout_complete(is_diffusers: bool, dir: &Path) -> bool {
3623 (is_diffusers || crate::download::cache_file_usable(&dir.join("config.json")))
3624 && mlx_dir_has_weights(dir)
3625}
3626
3627#[allow(dead_code)]
3647fn mlx_row_needs_in_process_loader(schema: &ModelSchema) -> bool {
3648 use crate::schema::ModelCapability as C;
3649 let tagged = |t: &str| schema.tags.iter().any(|x| x == t);
3650 if tagged("speech") || tagged("requires-mlx-vlm") {
3651 return false;
3652 }
3653 if schema
3654 .capabilities
3655 .iter()
3656 .any(|c| matches!(c, C::ImageGeneration | C::VideoGeneration))
3657 {
3658 return false;
3659 }
3660 matches!(schema.source, ModelSource::Mlx { .. })
3661}
3662
3663pub(crate) fn mlx_dir_has_weights(dir: &Path) -> bool {
3685 let index = dir.join("model.safetensors.index.json");
3689 if index.is_file() {
3690 return sharded_weight_files(&index).is_some_and(|required| {
3691 !required.is_empty()
3692 && required
3693 .iter()
3694 .all(|shard| crate::download::cache_file_usable(&dir.join(shard)))
3695 });
3696 }
3697 mlx_dir_has_weights_depth(dir, 0)
3698}
3699
3700pub(crate) fn missing_weight_shards(dir: &Path) -> Vec<String> {
3712 let index = dir.join("model.safetensors.index.json");
3713 if !index.is_file() {
3714 return Vec::new();
3715 }
3716 let Some(required) = sharded_weight_files(&index) else {
3717 return Vec::new();
3720 };
3721 required
3722 .into_iter()
3723 .filter(|shard| !dir.join(shard).exists())
3724 .collect()
3725}
3726
3727fn sharded_weight_files(index: &Path) -> Option<Vec<String>> {
3734 let raw = std::fs::read_to_string(index).ok()?;
3735 let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
3736 let map = parsed.get("weight_map")?.as_object()?;
3737 let mut files: Vec<String> = map
3738 .values()
3739 .filter_map(|v| v.as_str().map(str::to_string))
3740 .collect();
3741 files.sort();
3742 files.dedup();
3743 Some(files)
3744}
3745
3746fn mlx_dir_has_weights_depth(dir: &Path, depth: usize) -> bool {
3757 if depth > 4 {
3758 return false;
3759 }
3760 let Ok(rd) = std::fs::read_dir(dir) else {
3761 return false;
3762 };
3763 rd.flatten().any(|e| {
3764 let p = e.path();
3765 let is_symlink = std::fs::symlink_metadata(&p)
3766 .map(|m| m.file_type().is_symlink())
3767 .unwrap_or(true);
3768 if p.is_dir() {
3769 !is_symlink && mlx_dir_has_weights_depth(&p, depth + 1)
3770 } else {
3771 p.extension().and_then(|x| x.to_str()) == Some("safetensors")
3775 && crate::download::cache_file_usable(&p)
3776 }
3777 })
3778}
3779
3780#[allow(dead_code)] fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
3782 latest_huggingface_repo_snapshot(repo_id).is_some()
3783}
3784
3785fn resolve_huggingface_ref_snapshot(repo_dir: &Path, name: &str) -> Option<PathBuf> {
3786 let sha = std::fs::read_to_string(repo_dir.join("refs").join(name))
3787 .ok()?
3788 .trim()
3789 .to_string();
3790 if sha.is_empty() {
3791 return None;
3792 }
3793
3794 let snapshot = repo_dir.join("snapshots").join(sha);
3795 if snapshot_looks_ready(&snapshot) {
3796 Some(snapshot)
3797 } else {
3798 None
3799 }
3800}
3801
3802pub(crate) fn latest_huggingface_repo_snapshot(repo_id: &str) -> Option<PathBuf> {
3803 let repo_dir = crate::hf_cache::repo_dir(repo_id);
3804 latest_huggingface_repo_snapshot_in(&repo_dir)
3805}
3806
3807pub(crate) fn latest_huggingface_repo_snapshot_in(repo_dir: &Path) -> Option<PathBuf> {
3808 if let Some(snapshot) = resolve_huggingface_ref_snapshot(repo_dir, "main") {
3809 return Some(snapshot);
3810 }
3811
3812 let snapshots = repo_dir.join("snapshots");
3813 let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(snapshots)
3814 .ok()?
3815 .filter_map(Result::ok)
3816 .map(|e| e.path())
3817 .filter(|p| p.is_dir() && snapshot_looks_ready(p))
3818 .map(|path| {
3819 let modified = path
3820 .metadata()
3821 .and_then(|metadata| metadata.modified())
3822 .unwrap_or(SystemTime::UNIX_EPOCH);
3823 (modified, path)
3824 })
3825 .collect();
3826 candidates.sort();
3827 candidates.pop().map(|(_, path)| path)
3828}
3829
3830fn snapshot_looks_ready(path: &Path) -> bool {
3831 if path.join("config.json").exists() || path.join("model_index.json").exists() {
3832 return true;
3833 }
3834 snapshot_contains_ext(path, "safetensors")
3835}
3836
3837fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
3838 let Ok(entries) = std::fs::read_dir(root) else {
3839 return false;
3840 };
3841 entries.filter_map(Result::ok).any(|entry| {
3842 let path = entry.path();
3843 if path.is_dir() {
3844 snapshot_contains_ext(&path, ext)
3845 } else {
3846 let ext_matches = path
3847 .extension()
3848 .and_then(|value| value.to_str())
3849 .map(|value| value.eq_ignore_ascii_case(ext))
3850 .unwrap_or(false);
3851 ext_matches && crate::download::cache_file_usable(&path)
3855 }
3856 })
3857}
3858
3859const BUILTIN_CATALOG_JSON: &str = include_str!("builtin_catalog.json");
3868
3869static BUILTIN_CATALOG: std::sync::LazyLock<Vec<ModelSchema>> = std::sync::LazyLock::new(|| {
3870 serde_json::from_str(BUILTIN_CATALOG_JSON)
3871 .expect("builtin_catalog.json failed to parse — fix the JSON, not this code")
3872});
3873
3874pub(crate) fn builtin_catalog() -> Vec<ModelSchema> {
3875 let mut catalog = BUILTIN_CATALOG.clone();
3876 catalog.extend(crate::openrouter::builtin_schemas());
3877 catalog
3878}
3879
3880pub const EXTRA_CREDENTIAL_ENV_NAMES: &[&str] = &[
3883 "OPENAI_API_KEY",
3884 "OPENROUTER_API_KEY",
3885 "ANTHROPIC_API_KEY",
3886 "ANTHROPIC_AUTH_TOKEN",
3887 "GEMINI_API_KEY",
3888 "GOOGLE_API_KEY",
3889 "ELEVENLABS_API_KEY",
3890 "GITHUB_TOKEN",
3891 "GH_TOKEN",
3892 "AWS_ACCESS_KEY_ID",
3893 "AWS_SECRET_ACCESS_KEY",
3894 "AWS_SESSION_TOKEN",
3895 "TAVILY_API_KEY",
3896 "HF_TOKEN",
3897 "CODEX_API_KEY",
3898 "PARSLEE_ORG_GRANTER_SEED",
3899 "PARSLEE_ORG_GRANTER_SEEDS",
3900];
3901
3902const CREDENTIAL_NAME_SUFFIXES: &[&str] = &[
3903 "_API_KEY",
3904 "_APIKEY",
3905 "_TOKEN",
3906 "_SECRET",
3907 "_PASSWORD",
3908 "_PASSWD",
3909 "_ACCESS_KEY",
3910 "_PRIVATE_KEY",
3911 "_CREDENTIALS",
3912];
3913
3914pub fn is_credential_env_name(name: &str) -> bool {
3920 let upper = name.to_ascii_uppercase();
3921 EXTRA_CREDENTIAL_ENV_NAMES.contains(&upper.as_str())
3922 || CREDENTIAL_NAME_SUFFIXES
3923 .iter()
3924 .any(|suffix| upper.ends_with(suffix))
3925 || builtin_credential_env_names().contains(name)
3926}
3927
3928pub fn builtin_credential_env_names() -> std::collections::BTreeSet<String> {
3934 let mut names = std::collections::BTreeSet::new();
3935 for model in builtin_catalog() {
3936 match model.source {
3937 ModelSource::RemoteApi {
3938 api_key_env,
3939 api_key_envs,
3940 ..
3941 } => {
3942 names.insert(api_key_env);
3943 names.extend(api_key_envs);
3944 }
3945 ModelSource::Proprietary { auth, .. } => match auth {
3946 ProprietaryAuth::ApiKeyEnv { env_var }
3947 | ProprietaryAuth::BearerTokenEnv { env_var } => {
3948 names.insert(env_var);
3949 }
3950 ProprietaryAuth::OAuth2Pkce { .. } | ProprietaryAuth::ChatGptSubscription {} => {}
3951 },
3952 ModelSource::Local { .. }
3953 | ModelSource::CodexCli { .. }
3954 | ModelSource::Ollama { .. }
3955 | ModelSource::Mlx { .. }
3956 | ModelSource::WhisperCpp { .. }
3957 | ModelSource::WindowsSpeech {}
3958 | ModelSource::VllmMlx { .. }
3959 | ModelSource::ManagedVllmMlx { .. }
3960 | ModelSource::AppleFoundationModels { .. }
3961 | ModelSource::Delegated { .. } => {}
3962 }
3963 }
3964 names
3965}
3966
3967pub fn is_builtin_subscription_billed(model_id: &str) -> bool {
3975 let latest_id = implicit_latest_id(model_id);
3976 builtin_catalog().into_iter().any(|model| {
3977 (model.id == model_id || latest_id.as_deref() == Some(model.id.as_str()))
3978 && model.is_subscription_billed()
3979 })
3980}
3981
3982#[doc(hidden)]
3985pub fn builtin_catalog_with_huggingface_hub_for_testing(
3986 models_dir: &Path,
3987 huggingface_hub_root: &Path,
3988) -> Vec<ModelSchema> {
3989 let mut catalog = builtin_catalog();
3990 for schema in &mut catalog {
3991 schema.weights_ready = physical_weights_ready_with_huggingface_hub(
3992 schema,
3993 models_dir,
3994 Some(huggingface_hub_root),
3995 );
3996 }
3997 catalog
3998}
3999
4000pub fn implicit_latest_id(model: &str) -> Option<String> {
4004 (!model.contains(':')).then(|| format!("{model}:latest"))
4005}
4006
4007pub fn find_model_row<'a>(rows: &'a [ModelSchema], model: &str) -> Option<&'a ModelSchema> {
4014 rows.iter()
4015 .find(|row| row.id == model)
4016 .or_else(|| find_named(model, rows.iter(), |row| &row.name))
4017 .or_else(|| {
4018 let latest = implicit_latest_id(model)?;
4019 rows.iter().find(|row| row.id == latest)
4020 })
4021}
4022
4023pub(crate) fn find_named<T>(
4029 name: &str,
4030 mut items: impl Iterator<Item = T> + Clone,
4031 name_of: impl Fn(&T) -> &str,
4032) -> Option<T> {
4033 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4034 if !name.to_ascii_lowercase().ends_with("-mlx") {
4035 let mlx = format!("{name}-MLX");
4036 if let Some(mlx_variant) = items
4037 .clone()
4038 .find(|item| name_of(item).eq_ignore_ascii_case(&mlx))
4039 {
4040 return Some(mlx_variant);
4041 }
4042 }
4043
4044 items.find(|item| name_of(item).eq_ignore_ascii_case(name))
4045}
4046
4047#[cfg(test)]
4048mod tests {
4049 use crate::openrouter::StateRootScope;
4050
4051 #[test]
4052 fn chat_template_missing_only_when_no_template_anywhere() {
4053 let dir = tempfile::tempdir().unwrap();
4054 let cfg = dir.path().join("tokenizer_config.json");
4055 assert!(!chat_template_missing(dir.path()));
4057 std::fs::write(&cfg, r#"{"eos_token":"<|endoftext|>"}"#).unwrap();
4058 assert!(chat_template_missing(dir.path()));
4059 std::fs::write(&cfg, r#"{"chat_template":"{{ messages }}"}"#).unwrap();
4060 assert!(!chat_template_missing(dir.path()));
4061 std::fs::write(&cfg, r#"{"eos_token":"<|endoftext|>"}"#).unwrap();
4062 std::fs::write(dir.path().join("chat_template.jinja"), "{{ messages }}").unwrap();
4063 assert!(!chat_template_missing(dir.path()));
4064 }
4065
4066 #[test]
4074 fn a_sharded_model_missing_one_shard_is_not_installed() {
4075 let tmp = tempfile::tempdir().unwrap();
4076 let dir = tmp.path();
4077 std::fs::write(
4078 dir.join("model.safetensors.index.json"),
4079 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
4080 "b":"model-00002-of-00002.safetensors"}}"#,
4081 )
4082 .unwrap();
4083 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
4085 assert!(
4086 !mlx_dir_has_weights(dir),
4087 "a missing shard must read as not-installed, or pull silently no-ops"
4088 );
4089
4090 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
4092 assert!(
4093 mlx_dir_has_weights(dir),
4094 "a complete shard set must read as installed"
4095 );
4096 }
4097
4098 #[test]
4106 fn missing_shards_are_reported_by_name() {
4107 let tmp = tempfile::tempdir().unwrap();
4108 let dir = tmp.path();
4109 std::fs::write(
4110 dir.join("model.safetensors.index.json"),
4111 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
4112 "b":"model-00002-of-00002.safetensors"}}"#,
4113 )
4114 .unwrap();
4115 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
4116
4117 assert_eq!(
4118 missing_weight_shards(dir),
4119 vec!["model-00001-of-00002.safetensors".to_string()],
4120 "the absent shard must be named, not just counted"
4121 );
4122
4123 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
4124 assert!(
4125 missing_weight_shards(dir).is_empty(),
4126 "a complete shard set must report nothing missing"
4127 );
4128 }
4129
4130 #[test]
4134 fn missing_shards_is_empty_without_an_index() {
4135 let tmp = tempfile::tempdir().unwrap();
4136 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
4137 assert!(missing_weight_shards(tmp.path()).is_empty());
4138
4139 let bad = tempfile::tempdir().unwrap();
4142 std::fs::write(bad.path().join("model.safetensors.index.json"), b"not json").unwrap();
4143 assert!(missing_weight_shards(bad.path()).is_empty());
4144 }
4145
4146 #[test]
4148 fn a_single_file_model_still_counts_without_an_index() {
4149 let tmp = tempfile::tempdir().unwrap();
4150 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
4151 assert!(mlx_dir_has_weights(tmp.path()));
4152 }
4153
4154 #[test]
4157 fn an_unparseable_index_fails_closed() {
4158 let tmp = tempfile::tempdir().unwrap();
4159 std::fs::write(
4160 tmp.path().join("model.safetensors.index.json"),
4161 b"{not-json",
4162 )
4163 .unwrap();
4164 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
4165 assert!(
4166 !mlx_dir_has_weights(tmp.path()),
4167 "an unreadable index must not fall back to a stray weight"
4168 );
4169 }
4170
4171 use super::*;
4172 use tempfile::TempDir;
4173
4174 #[test]
4175 fn mlx_dir_has_weights_detects_completeness() {
4176 let tmp = TempDir::new().unwrap();
4177 let dir = tmp.path();
4178
4179 std::fs::write(dir.join("config.json"), "{}").unwrap();
4181 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
4182 assert!(
4183 !mlx_dir_has_weights(dir),
4184 "config-only stub must not count as installed"
4185 );
4186
4187 std::fs::write(dir.join("model.safetensors.index.json"), "{}").unwrap();
4189 assert!(!mlx_dir_has_weights(dir), "index.json alone is not weights");
4190
4191 std::fs::remove_file(dir.join("model.safetensors.index.json")).unwrap();
4193 std::fs::write(dir.join("model.safetensors"), b"\x00\x01\x02").unwrap();
4194 assert!(mlx_dir_has_weights(dir));
4195 }
4196
4197 #[test]
4198 fn mlx_dir_has_weights_handles_sharded_and_dangling_symlinks() {
4199 let sharded = TempDir::new().unwrap();
4200 std::fs::write(sharded.path().join("config.json"), "{}").unwrap();
4201 std::fs::write(
4202 sharded.path().join("model-00001-of-00002.safetensors"),
4203 b"\x00",
4204 )
4205 .unwrap();
4206 assert!(mlx_dir_has_weights(sharded.path()), "sharded shard counts");
4207
4208 #[cfg(unix)]
4211 {
4212 let dangling = TempDir::new().unwrap();
4213 std::fs::write(dangling.path().join("config.json"), "{}").unwrap();
4214 std::os::unix::fs::symlink(
4215 dangling.path().join("does-not-exist"),
4216 dangling.path().join("model.safetensors"),
4217 )
4218 .unwrap();
4219 assert!(
4220 !mlx_dir_has_weights(dangling.path()),
4221 "dangling weight symlink must count as absent"
4222 );
4223 }
4224 }
4225
4226 struct TestRegistry {
4248 registry: UnifiedRegistry,
4249 _tmp: TempDir,
4253 _environment: tokio::sync::MutexGuard<'static, ()>,
4254 }
4255
4256 impl std::ops::Deref for TestRegistry {
4257 type Target = UnifiedRegistry;
4258
4259 fn deref(&self) -> &Self::Target {
4260 &self.registry
4261 }
4262 }
4263
4264 impl std::ops::DerefMut for TestRegistry {
4265 fn deref_mut(&mut self) -> &mut Self::Target {
4266 &mut self.registry
4267 }
4268 }
4269
4270 fn test_registry() -> TestRegistry {
4271 let _environment = crate::openrouter::test_environment_scope();
4272 let tmp = TempDir::new().unwrap();
4273 let registry = UnifiedRegistry::new_with_state_root(
4274 tmp.path().to_path_buf(),
4275 tmp.path().join("models"),
4276 );
4277 TestRegistry {
4278 registry,
4279 _tmp: tmp,
4280 _environment,
4281 }
4282 }
4283
4284 #[test]
4289 fn a_rows_unknown_credential_state_follows_the_credential_it_needs() {
4290 let row = |source: serde_json::Value, provider: &str| -> ModelSchema {
4291 serde_json::from_value(serde_json::json!({
4292 "id": format!("{provider}/row"), "name": "row", "provider": provider,
4293 "family": "test", "capabilities": ["generate"], "context_length": 1000,
4294 "source": source,
4295 }))
4296 .unwrap()
4297 };
4298 let keyed = row(
4299 serde_json::json!({"type": "remote_api", "endpoint": "https://example.invalid",
4300 "api_key_env": "VENDOR_API_KEY", "api_key_envs": [], "protocol": "anthropic"}),
4301 "vendor",
4302 );
4303 let codex = row(
4304 serde_json::json!({"type": "proprietary", "provider": "openai-codex",
4305 "endpoint": "https://example.invalid", "auth": {"type": "chatgpt_subscription"},
4306 "protocol": {}}),
4307 "openai-codex",
4308 );
4309 let openrouter = row(
4310 serde_json::json!({"type": "remote_api", "endpoint": "https://example.invalid",
4311 "api_key_env": "OPENROUTER_API_KEY", "api_key_envs": [], "protocol": "open_router"}),
4312 "openrouter",
4313 );
4314 let parslee = row(
4315 serde_json::json!({"type": "proprietary", "provider": "parslee",
4316 "endpoint": "https://api.parslee.ai", "auth": {"type": "o_auth2_pkce",
4317 "authority": "https://api.parslee.ai", "client_id": "parslee-car", "scopes": []},
4318 "protocol": {}}),
4319 "parslee",
4320 );
4321
4322 let mut registry = UnifiedRegistry::new_empty(std::path::PathBuf::from("/nonexistent"));
4323 for schema in [&keyed, &codex, &openrouter, &parslee] {
4324 assert!(
4325 !registry.row_credential_state_unknown(schema),
4326 "{}",
4327 schema.id
4328 );
4329 }
4330 registry
4331 .credential_state_unknown
4332 .insert("VENDOR_API_KEY".into());
4333 registry.codex_state_unknown = true;
4334 registry.openrouter_state_unknown = true;
4335 assert!(registry.row_credential_state_unknown(&keyed));
4336 assert!(registry.row_credential_state_unknown(&codex));
4337 assert!(registry.row_credential_state_unknown(&openrouter));
4338 assert!(!registry.row_credential_state_unknown(&parslee));
4339 }
4340
4341 #[test]
4355 fn an_unreadable_credential_is_recorded_as_unknown_not_absent() {
4356 let registry = UnifiedRegistry::new_empty(std::path::PathBuf::from("/nonexistent"));
4360 assert!(
4361 !registry.credential_state_is_unknown("VENDOR_API_KEY"),
4362 "an unprobed registry must not claim a credential state is unknown"
4363 );
4364
4365 assert_eq!(
4369 credential_presence("CAR_TEST_CREDENTIAL_THAT_DOES_NOT_EXIST"),
4370 CredentialPresence::Absent
4371 );
4372 }
4373
4374 #[test]
4375 fn the_withheld_set_covers_keys_and_leaves_settings() {
4376 for name in builtin_credential_env_names() {
4377 assert!(is_credential_env_name(&name), "{name}");
4378 }
4379 for name in EXTRA_CREDENTIAL_ENV_NAMES.iter().chain(&[
4380 "MY_VENDOR_API_KEY",
4381 "SOME_SERVICE_TOKEN",
4382 "DB_PASSWORD",
4383 "X_SECRET",
4384 "CAR_AUTH_TOKEN",
4385 ]) {
4386 assert!(is_credential_env_name(name), "{name}");
4387 }
4388 for name in [
4389 "CAR_NO_AUTH",
4390 "VLLM_MLX_ENDPOINT",
4391 "RUST_LOG",
4392 "CAR_MCP_BIND",
4393 "PARSLEE_SYNC_ORG_SCOPE",
4394 "TOKHN_STT_PROVIDER",
4395 "PARSLEE_API_BASE",
4396 ] {
4397 assert!(!is_credential_env_name(name), "{name}");
4398 }
4399 }
4400
4401 #[tokio::test(flavor = "current_thread")]
4405 async fn a_key_kept_only_in_the_store_reads_as_present_and_one_in_a_hand_made_env_file_does_not(
4406 ) {
4407 let _env = crate::openrouter::test_environment_scope_async().await;
4408 const NAME: &str = "CAR_PRESENCE_TEST_API_KEY";
4409 let home = tempfile::tempdir().unwrap();
4410 let store = home.path().join("store");
4411 std::fs::create_dir_all(&store).unwrap();
4412 std::fs::write(home.path().join("env"), format!("{NAME}=fake-from-file\n")).unwrap();
4413 let previous = (
4414 std::env::var_os(car_home::ENV_VAR),
4415 std::env::var_os("CAR_SECRETS_FILE_DIR"),
4416 std::env::var_os(NAME),
4417 );
4418 std::env::set_var(car_home::ENV_VAR, home.path());
4419 std::env::set_var("CAR_SECRETS_FILE_DIR", &store);
4420 std::env::remove_var(NAME);
4421
4422 let file_only = credential_presence(NAME);
4423 let secrets = car_secrets::ActivityScope::begin();
4424 car_secrets::SecretStore::new()
4425 .put(
4426 &car_secrets::SecretRef::with_default_service(NAME),
4427 "fake-from-store",
4428 )
4429 .unwrap();
4430 let before = secrets.activity();
4431 let store_only = credential_presence(NAME);
4432 let reads = secrets.activity().get_attempts - before.get_attempts;
4433
4434 match previous.0 {
4435 Some(value) => std::env::set_var(car_home::ENV_VAR, value),
4436 None => std::env::remove_var(car_home::ENV_VAR),
4437 }
4438 match previous.1 {
4439 Some(value) => std::env::set_var("CAR_SECRETS_FILE_DIR", value),
4440 None => std::env::remove_var("CAR_SECRETS_FILE_DIR"),
4441 }
4442 if let Some(value) = previous.2 {
4443 std::env::set_var(NAME, value);
4444 }
4445 assert_eq!(file_only, CredentialPresence::Absent);
4446 assert_eq!(store_only, CredentialPresence::Present);
4447 assert_eq!(reads, 0, "presence must not decrypt the value");
4448 }
4449
4450 #[test]
4451 fn credential_required_names_a_key_only_when_a_key_is_the_answer() {
4452 let remote = |available: bool, deprecated: bool| {
4453 let mut m = test_generate_schema(
4454 "vendor/m",
4455 "m",
4456 ModelSource::RemoteApi {
4457 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4458 endpoint: "https://example.invalid/v1".into(),
4459 api_key_env: "VENDOR_API_KEY".into(),
4460 api_key_envs: vec![],
4461 api_version: None,
4462 },
4463 );
4464 m.provider = "vendor".into();
4465 m.available = available;
4466 m.deprecated = deprecated;
4467 m
4468 };
4469
4470 assert_eq!(
4471 remote(false, false).credential_required().as_deref(),
4472 Some("VENDOR_API_KEY"),
4473 "an unavailable plain remote row is exactly the case this field is for"
4474 );
4475
4476 assert_eq!(
4477 remote(true, false).credential_required(),
4478 None,
4479 "an available row needs nothing"
4480 );
4481 assert_eq!(
4482 remote(false, true).credential_required(),
4483 None,
4484 "a deprecated row does not become usable by adding a key"
4485 );
4486
4487 let mut multi = remote(false, false);
4491 if let ModelSource::RemoteApi {
4492 ref mut api_key_envs,
4493 ..
4494 } = multi.source
4495 {
4496 *api_key_envs = vec!["VENDOR_A".into(), "VENDOR_B".into()];
4497 }
4498 assert_eq!(multi.credential_required(), None);
4499
4500 let mut router = remote(false, false);
4503 router.source = ModelSource::RemoteApi {
4504 protocol: crate::schema::ApiProtocol::OpenRouter,
4505 endpoint: "https://openrouter.ai/api/v1".into(),
4506 api_key_env: "OPENROUTER_API_KEY".into(),
4507 api_key_envs: vec![],
4508 api_version: None,
4509 };
4510 assert_eq!(router.credential_required(), None);
4511
4512 let mut local = test_generate_schema(
4514 "mlx/local",
4515 "local",
4516 ModelSource::Mlx {
4517 hf_repo: "org/repo".into(),
4518 hf_weight_file: None,
4519 },
4520 );
4521 local.available = false;
4522 assert_eq!(local.credential_required(), None);
4523 }
4524
4525 fn test_generate_schema(id: &str, name: &str, source: ModelSource) -> ModelSchema {
4526 ModelSchema {
4527 id: id.into(),
4528 name: name.into(),
4529 provider: "local".into(),
4530 family: "qwen3".into(),
4531 version: "test".into(),
4532 capabilities: vec![ModelCapability::Generate],
4533 context_length: 4096,
4534 max_output_tokens: None,
4535 param_count: String::new(),
4536 quantization: None,
4537 performance: PerformanceEnvelope::default(),
4538 cost: CostModel::default(),
4539 source,
4540 tags: vec![],
4541 supported_params: vec![],
4542 public_benchmarks: vec![],
4543 trust_tier: crate::schema::TrustTier::Curated,
4544 deprecated: false,
4545 available: false,
4546 weights_ready: false,
4547 }
4548 }
4549
4550 #[test]
4555 fn model_info_carries_weights_ready_through_the_projection() {
4556 let mut schema = test_generate_schema(
4557 "mlx-community/car894-test-4bit",
4558 "car894-test-4bit",
4559 ModelSource::Mlx {
4560 hf_repo: "mlx-community/car894-test-4bit".into(),
4561 hf_weight_file: None,
4562 },
4563 );
4564
4565 schema.weights_ready = false;
4566 assert!(
4567 !ModelInfo::from(&schema).weights_ready,
4568 "a schema with no weights on disk must project weights_ready = false"
4569 );
4570
4571 schema.weights_ready = true;
4572 assert!(
4573 ModelInfo::from(&schema).weights_ready,
4574 "a schema with weights on disk must project weights_ready = true"
4575 );
4576 }
4577
4578 #[test]
4584 fn model_info_carries_downloads_weights_through_the_projection() {
4585 let mlx = test_generate_schema(
4586 "mlx-community/car894-test-4bit",
4587 "car894-test-4bit",
4588 ModelSource::Mlx {
4589 hf_repo: "mlx-community/car894-test-4bit".into(),
4590 hf_weight_file: None,
4591 },
4592 );
4593 assert!(
4594 ModelInfo::from(&mlx).downloads_weights,
4595 "an MLX entry downloads weights"
4596 );
4597
4598 for (label, source) in [
4601 ("windows speech", ModelSource::WindowsSpeech {}),
4602 (
4603 "apple foundation",
4604 ModelSource::AppleFoundationModels { use_case: None },
4605 ),
4606 ] {
4607 let schema = test_generate_schema("car894/os-model", "os-model", source);
4608 let info = ModelInfo::from(&schema);
4609 assert!(
4610 !info.downloads_weights,
4611 "{label} installs nothing, so the projection must say so"
4612 );
4613 assert!(
4614 info.is_local,
4615 "{label} is still local — which is exactly why is_local cannot stand in"
4616 );
4617 }
4618
4619 let external = test_generate_schema(
4624 "car894/external-model",
4625 "external-model",
4626 ModelSource::VllmMlx {
4627 endpoint: "http://localhost:8000".into(),
4628 model_name: "mlx-community/car894-test-4bit".into(),
4629 },
4630 );
4631 assert!(!external.is_local());
4632 assert!(external.is_remote());
4633 assert!(!external.requires_apple_silicon());
4634 let info = ModelInfo::from(&external);
4635 assert!(!info.is_local);
4636 assert!(
4637 !info.downloads_weights,
4638 "external vllm-mlx owns its weights, so CAR installs nothing"
4639 );
4640 }
4641
4642 #[test]
4643 fn model_info_classifies_only_raw_vllm_mlx_as_operator_managed_external() {
4644 for endpoint in ["http://localhost:8000", "https://models.example.invalid/v1"] {
4645 let schema = test_generate_schema(
4646 "external/model",
4647 "external-model",
4648 ModelSource::VllmMlx {
4649 endpoint: endpoint.into(),
4650 model_name: "mlx-community/external-model".into(),
4651 },
4652 );
4653 let info = ModelInfo::from(&schema);
4654 assert!(info.operator_managed_external_runtime);
4655 assert_eq!(
4656 serde_json::to_value(info).unwrap()["operator_managed_external_runtime"],
4657 true
4658 );
4659 }
4660
4661 for source in [
4662 ModelSource::RemoteApi {
4663 endpoint: "https://cloud.example.invalid/v1".into(),
4664 api_key_env: "CAR_TEST_KEY".into(),
4665 api_key_envs: vec![],
4666 api_version: None,
4667 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4668 },
4669 ModelSource::ManagedVllmMlx {
4670 hf_repo: "mlx-community/car-owned-model".into(),
4671 hf_weight_file: None,
4672 draft_hf_repo: None,
4673 },
4674 ] {
4675 assert!(
4676 !ModelInfo::from(&test_generate_schema(
4677 "not-external/model",
4678 "not-external-model",
4679 source,
4680 ))
4681 .operator_managed_external_runtime
4682 );
4683 }
4684 }
4685
4686 #[test]
4693 fn fresh_machine_mlx_entry_is_available_but_not_weights_ready() {
4694 let mut reg = test_registry();
4695 let id = "mlx-community/car894-fresh-4bit";
4696 reg.register(test_generate_schema(
4697 id,
4698 "car894-fresh-4bit",
4699 ModelSource::Mlx {
4700 hf_repo: "mlx-community/car894-fresh-4bit".into(),
4701 hf_weight_file: None,
4702 },
4703 ));
4704
4705 let registered = reg
4706 .get(id)
4707 .expect("the model just registered must be in the registry");
4708 let info = ModelInfo::from(registered);
4709
4710 assert!(
4712 !registered.weights_ready,
4713 "an empty models dir means no weights on disk"
4714 );
4715 assert!(
4716 !info.weights_ready,
4717 "the CLI-facing projection must report the same: nothing installed"
4718 );
4719
4720 #[cfg(car_mlxlm_swift_built)]
4721 {
4722 assert!(
4725 registered.available,
4726 "a declared hf_repo makes an MLX entry runnable before download (#164)"
4727 );
4728 assert!(
4729 info.available,
4730 "the projection must keep reporting it as runnable"
4731 );
4732 }
4733 #[cfg(not(car_mlxlm_swift_built))]
4734 {
4735 assert!(
4738 !registered.available,
4739 "the Swift MLX backend is absent, so it must not be runnable"
4740 );
4741 assert!(!info.available);
4742 }
4743 }
4744
4745 fn write_complete_mlx_snapshot(dir: &Path) {
4746 std::fs::create_dir_all(dir).unwrap();
4747 std::fs::write(dir.join("config.json"), b"{}").unwrap();
4748 std::fs::write(dir.join("tokenizer.json"), b"{}").unwrap();
4749 std::fs::write(
4750 dir.join("model.safetensors.index.json"),
4751 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
4752 )
4753 .unwrap();
4754 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"one").unwrap();
4755 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
4756 }
4757
4758 #[test]
4759 fn complete_managed_and_shared_mlx_snapshots_are_physically_ready() {
4760 let schema = test_generate_schema(
4761 "mlx/qwen3-4b:4bit",
4762 "Qwen3-4B-MLX",
4763 ModelSource::Mlx {
4764 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
4765 hf_weight_file: None,
4766 },
4767 );
4768 let root = tempfile::tempdir().unwrap();
4769 let managed = root.path().join("managed");
4770 let shared = root.path().join("shared");
4771
4772 write_complete_mlx_snapshot(&managed);
4773 assert!(mlx_weights_ready_at(&schema, &managed, None));
4774
4775 std::fs::remove_dir_all(&managed).unwrap();
4776 write_complete_mlx_snapshot(&shared);
4777 assert!(mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4778 }
4779
4780 #[test]
4781 fn zero_byte_gguf_is_not_physically_ready() {
4782 let schema = test_generate_schema(
4783 "qwen/qwen3-4b:q4_k_m",
4784 "Qwen3-4B",
4785 ModelSource::Local {
4786 hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
4787 hf_filename: "model.gguf".into(),
4788 tokenizer_repo: "Qwen/Qwen3-4B".into(),
4789 },
4790 );
4791 let root = tempfile::tempdir().unwrap();
4792 let model_dir = root.path().join(&schema.name);
4793 std::fs::create_dir_all(&model_dir).unwrap();
4794 std::fs::write(model_dir.join("model.gguf"), b"").unwrap();
4795
4796 assert!(!physical_weights_ready(&schema, root.path()));
4797 std::fs::write(model_dir.join("model.gguf"), b"gguf").unwrap();
4798 assert!(physical_weights_ready(&schema, root.path()));
4799 }
4800
4801 #[test]
4802 fn shared_mlx_snapshot_missing_an_indexed_shard_is_not_physically_ready() {
4803 let schema = test_generate_schema(
4804 "mlx/qwen3-8b:4bit",
4805 "Qwen3-8B-MLX",
4806 ModelSource::Mlx {
4807 hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
4808 hf_weight_file: None,
4809 },
4810 );
4811 let root = tempfile::tempdir().unwrap();
4812 let managed = root.path().join("managed");
4813 let shared = root.path().join("shared");
4814 write_complete_mlx_snapshot(&shared);
4815 std::fs::remove_file(shared.join("model-00001-of-00002.safetensors")).unwrap();
4816
4817 assert!(!mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4818 }
4819
4820 #[test]
4852 fn a_gateway_that_reports_no_upstream_stops_being_advertised() {
4853 let _guard = crate::openrouter::test_environment_scope();
4854 let _home = StateRootScope::new();
4857 crate::openrouter::clear_gateway_unconfigured();
4858
4859 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
4860 .into_iter()
4861 .filter(|s| crate::openrouter::is_curated_managed_gateway_alias(&s.id))
4862 .collect();
4863 assert!(
4864 !managed.is_empty(),
4865 "precondition: the curated catalog must still carry managed aliases"
4866 );
4867
4868 let availability_of = |schema: &ModelSchema| match &schema.source {
4869 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
4870 &schema.id,
4871 &schema.provider,
4872 provider,
4873 auth,
4874 true,
4876 &std::collections::HashMap::new(),
4877 ),
4878 other => panic!("managed aliases must be Proprietary, got {other:?}"),
4879 };
4880
4881 assert!(
4882 managed.iter().all(availability_of),
4883 "precondition: an authenticated session advertises these today"
4884 );
4885
4886 crate::openrouter::note_gateway_unconfigured();
4887 assert!(
4888 managed.iter().all(|s| !availability_of(s)),
4889 "after the gateway says it has no OpenRouter upstream, every alias in \
4890 the namespace must report unavailable — that claim is what cost the \
4891 benchmark sweep in #786"
4892 );
4893
4894 crate::openrouter::clear_gateway_unconfigured();
4898 assert!(
4899 managed.iter().all(availability_of),
4900 "the suppression must be recoverable, not a one-way latch"
4901 );
4902 }
4903
4904 #[test]
4918 fn constructing_a_registry_with_a_live_session_leaves_the_gateway_observation_alone() {
4919 let _guard = crate::openrouter::test_environment_scope();
4920 let home = StateRootScope::new();
4921
4922 crate::openrouter::note_gateway_unconfigured();
4923 let _registry = UnifiedRegistry::new_with_session(
4924 home.path().to_path_buf(),
4925 home.path().join("models"),
4926 None,
4927 SessionProbe::Fixed(true),
4928 );
4929
4930 let observed = crate::openrouter::gateway_unconfigured();
4931 let persisted = crate::openrouter::gateway_state_path().exists();
4932
4933 crate::openrouter::clear_gateway_unconfigured();
4934
4935 assert!(
4936 observed,
4937 "a signed-in session has no reason to forget what the gateway said"
4938 );
4939 assert!(
4940 persisted,
4941 "the durable half of the observation must survive construction too"
4942 );
4943 }
4944
4945 #[test]
4953 fn constructing_a_registry_with_no_session_still_forgets_the_gateway_observation() {
4954 let _guard = crate::openrouter::test_environment_scope();
4955 let home = StateRootScope::new();
4956
4957 crate::openrouter::note_gateway_unconfigured();
4958 let recorded = crate::openrouter::gateway_unconfigured();
4959 let _registry = UnifiedRegistry::new_with_session(
4960 home.path().to_path_buf(),
4961 home.path().join("models"),
4962 None,
4963 SessionProbe::Fixed(false),
4964 );
4965
4966 let observed = crate::openrouter::gateway_unconfigured();
4967 let persisted = crate::openrouter::gateway_state_path().exists();
4968
4969 crate::openrouter::clear_gateway_unconfigured();
4970
4971 assert!(
4972 recorded,
4973 "precondition: the observation is on record before construction"
4974 );
4975 assert!(
4976 !observed,
4977 "sign-out must still discard the session-scoped verdict (#786)"
4978 );
4979 assert!(
4980 !persisted,
4981 "and the durable copy with it — otherwise the next sign-in inherits it from disk"
4982 );
4983 }
4984
4985 #[test]
5002 fn an_ordinary_test_registry_does_not_disturb_a_separately_set_observation() {
5003 let _guard = crate::openrouter::test_environment_scope();
5004 let home = StateRootScope::new();
5005
5006 crate::openrouter::note_gateway_unconfigured();
5007 let _registry = UnifiedRegistry::new_with_state_root(
5008 home.path().to_path_buf(),
5009 home.path().join("models"),
5010 );
5011
5012 let observed = crate::openrouter::gateway_unconfigured();
5013
5014 crate::openrouter::clear_gateway_unconfigured();
5015
5016 assert!(
5017 observed,
5018 "constructing a registry is not a statement about the session, so it \
5019 must not erase an observation another test just recorded (#986)"
5020 );
5021 assert!(
5022 !SessionProbe::Inert.may_forget_session_evidence(),
5023 "the `cfg(test)` construction default must be a probe that answers \
5024 the session question without acting on it — this is the half of \
5025 the guarantee that does not depend on whether the runner happens \
5026 to be signed in"
5027 );
5028 }
5029
5030 #[test]
5043 fn a_rejected_credential_stops_the_managed_lane_being_advertised() {
5044 let _guard = crate::openrouter::test_environment_scope();
5045 let _home = StateRootScope::new();
5046 crate::parslee_credential::clear_credential_rejected();
5047
5048 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
5049 .into_iter()
5050 .filter(|s| s.provider == "parslee")
5051 .collect();
5052 assert!(
5053 !managed.is_empty(),
5054 "precondition: the curated catalog must still carry parslee rows"
5055 );
5056
5057 let availability_of = |schema: &ModelSchema| match &schema.source {
5058 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
5059 &schema.id,
5060 &schema.provider,
5061 provider,
5062 auth,
5063 true,
5065 &std::collections::HashMap::new(),
5066 ),
5067 other => panic!("parslee rows must be Proprietary, got {other:?}"),
5068 };
5069
5070 assert!(
5071 managed.iter().all(availability_of),
5072 "precondition: an authenticated session advertises these today"
5073 );
5074
5075 crate::parslee_credential::note_credential_rejected();
5076 assert!(
5077 managed.iter().all(|s| !availability_of(s)),
5078 "after the server rejects the credential, EVERY parslee row must \
5079 report unavailable — unlike the gateway verdict this is not scoped \
5080 to the curated OpenRouter aliases, because a dead credential kills \
5081 the whole namespace"
5082 );
5083
5084 crate::parslee_credential::clear_credential_rejected();
5087 assert!(
5088 managed.iter().all(availability_of),
5089 "the suppression must lift once the credential works again"
5090 );
5091 }
5092
5093 #[test]
5110 fn an_explicit_status_refresh_sees_the_same_environment_credential() {
5111 let _environment = crate::openrouter::test_environment_scope();
5112 let tmp = TempDir::new().unwrap();
5113 let env_var = "CAR_TEST_EXPLICIT_STATUS_KEY";
5114 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
5115 let mut schema = test_generate_schema(
5116 "vendor/explicit-status",
5117 "explicit-status",
5118 ModelSource::RemoteApi {
5119 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5120 endpoint: "https://example.invalid/v1".into(),
5121 api_key_env: env_var.into(),
5122 api_key_envs: vec![],
5123 api_version: None,
5124 },
5125 );
5126 schema.provider = "vendor".into();
5127 registry.register(schema);
5128
5129 std::env::remove_var(env_var);
5130 registry.refresh_availability();
5131 assert!(
5132 !registry.get("vendor/explicit-status").unwrap().available,
5133 "no credential anywhere: the passive refresh must report unavailable"
5134 );
5135 registry.refresh_availability_for_explicit_status();
5136 assert!(
5137 !registry.get("vendor/explicit-status").unwrap().available,
5138 "no credential anywhere: the explicit refresh must agree"
5139 );
5140
5141 std::env::set_var(env_var, "sk-present");
5142 registry.refresh_availability();
5143 assert!(
5144 registry.get("vendor/explicit-status").unwrap().available,
5145 "exported credential: the passive refresh must see it"
5146 );
5147 registry.refresh_availability_for_explicit_status();
5148 assert!(
5149 registry.get("vendor/explicit-status").unwrap().available,
5150 "exported credential: the explicit refresh must see it too — if this fails \
5151 the authoritative mode is resolving through a path that cannot see the \
5152 environment, and `car models list` is answering from something else again"
5153 );
5154 std::env::remove_var(env_var);
5155 }
5156
5157 #[test]
5158 fn refresh_availability_probes_each_credential_once_not_per_model() {
5159 let _environment = crate::openrouter::test_environment_scope();
5172 let tmp = TempDir::new().unwrap();
5173 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
5174 for i in 0..25 {
5175 let mut schema = test_generate_schema(
5176 &format!("openrouter/model-{i}"),
5177 &format!("model-{i}"),
5178 ModelSource::RemoteApi {
5179 protocol: crate::schema::ApiProtocol::OpenRouter,
5180 endpoint: "https://openrouter.ai/api/v1".into(),
5181 api_key_env: "OPENROUTER_API_KEY".into(),
5182 api_key_envs: vec![],
5183 api_version: None,
5184 },
5185 );
5186 schema.provider = "openrouter".into();
5187 registry.register(schema);
5188 }
5189
5190 crate::openrouter::reset_credential_source_call_count();
5191 registry.refresh_availability();
5192 let calls = crate::openrouter::credential_source_call_count();
5193
5194 assert_eq!(
5195 calls, 1,
5196 "refresh_availability probed the OpenRouter credential {calls} times for 25 models; \
5197 it must resolve each distinct credential once per refresh, not once per model"
5198 );
5199 }
5200
5201 #[test]
5207 fn a_vllm_mlx_pull_targets_the_shared_huggingface_cache() {
5208 let repo = "mlx-community/Qwen3.8-27B-4bit";
5209 let dir = crate::hf_cache::repo_dir(repo);
5210 assert!(
5211 dir.ends_with("models--mlx-community--Qwen3.8-27B-4bit"),
5212 "got {}",
5213 dir.display()
5214 );
5215 assert!(
5216 dir.parent().is_some_and(|p| p.ends_with("hub")),
5217 "must live under the HF cache's hub/ root, got {}",
5218 dir.display()
5219 );
5220 }
5221
5222 #[test]
5225 fn external_vllm_mlx_does_not_become_managed_from_a_loopback_endpoint() {
5226 let _environment = crate::openrouter::test_environment_scope();
5239 let tmp = TempDir::new().unwrap();
5240 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
5241 let schema = test_generate_schema(
5242 "vllm-mlx/arch-the-rust-backend-cannot-load",
5243 "external-only-model",
5244 ModelSource::VllmMlx {
5245 endpoint: "http://localhost:8000".into(),
5246 model_name: "mlx-community/Qwen3.8-27B-4bit".into(),
5247 },
5248 );
5249 registry.register(schema);
5250
5251 assert!(
5253 std::env::var("VLLM_MLX_ENDPOINT").is_err(),
5254 "test precondition: VLLM_MLX_ENDPOINT must be unset"
5255 );
5256 registry.refresh_availability();
5257
5258 let model = registry
5259 .get("vllm-mlx/arch-the-rust-backend-cannot-load")
5260 .expect("registered model should be present");
5261 assert!(
5262 !model.available,
5263 "an external vllm-mlx row remains external even on loopback; only an \
5264 explicit ManagedVllmMlx source may use CAR's runtime"
5265 );
5266 }
5267
5268 #[test]
5269 fn user_config_load_and_save_force_community_trust() {
5270 let tmp = TempDir::new().unwrap();
5271 let models_dir = tmp.path().join("models");
5272 let config_path = tmp.path().join("models.json");
5273 let schema = test_generate_schema(
5274 "user/test-model",
5275 "user-test-model",
5276 ModelSource::RemoteApi {
5277 endpoint: "https://attacker.invalid/v1/chat/completions".into(),
5278 api_key_env: "CAR_USER_MODEL_TEST_KEY".into(),
5279 api_key_envs: vec![],
5280 api_version: None,
5281 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5282 },
5283 );
5284 let mut omitted_tier = serde_json::to_value(schema.clone()).unwrap();
5285 omitted_tier.as_object_mut().unwrap().remove("trust_tier");
5286 std::fs::write(
5287 &config_path,
5288 serde_json::to_vec_pretty(&vec![omitted_tier]).unwrap(),
5289 )
5290 .unwrap();
5291
5292 let mut loaded = UnifiedRegistry::new_empty(models_dir.clone());
5293 loaded.load_user_config().unwrap();
5294 assert_eq!(
5295 loaded.get("user/test-model").unwrap().trust_tier,
5296 crate::schema::TrustTier::Community
5297 );
5298
5299 let mut persisted = UnifiedRegistry::new_empty(models_dir);
5300 persisted.register_user_model(schema);
5301 persisted.save_user_config().unwrap();
5302 let saved: Vec<ModelSchema> =
5303 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5304 assert_eq!(saved.len(), 1);
5305 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
5306 }
5307
5308 #[test]
5309 fn persisted_user_model_cannot_shadow_managed_openrouter_alias() {
5310 let _environment = crate::openrouter::test_environment_scope();
5315 let tmp = TempDir::new().unwrap();
5316 let models_dir = tmp.path().join("models");
5317 let config_path = tmp.path().join("models.json");
5318 let mut shadow = crate::openrouter::curated_schemas()
5319 .into_iter()
5320 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
5321 .unwrap();
5322 shadow.provider = "attacker".into();
5323 std::fs::write(
5324 &config_path,
5325 serde_json::to_vec_pretty(&vec![shadow]).unwrap(),
5326 )
5327 .unwrap();
5328
5329 let registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
5330 let actual = registry
5331 .get("parslee/openrouter/frontier-general")
5332 .expect("compiled managed alias must remain present");
5333 assert_eq!(actual.provider, "parslee");
5334 assert_eq!(
5335 crate::openrouter::canonical_managed_gateway_selector(actual),
5336 Some("parslee/openrouter/frontier-general")
5337 );
5338 }
5339
5340 fn signed_remote(id: &str, name: &str) -> ModelSchema {
5341 test_generate_schema(
5342 id,
5343 name,
5344 ModelSource::RemoteApi {
5345 endpoint: "https://catalog.example/v1".into(),
5346 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5347 api_key_envs: vec![],
5348 api_version: None,
5349 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5350 },
5351 )
5352 }
5353
5354 #[test]
5355 fn a_user_registered_row_wins_a_live_signed_swap_and_stays_in_models_json() {
5356 let _environment = crate::openrouter::test_environment_scope();
5357 let tmp = TempDir::new().unwrap();
5358 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5359 tmp.path().to_path_buf(),
5360 tmp.path().join("models"),
5361 None,
5362 );
5363 let mut mine = signed_remote("shared/id", "mine");
5364 mine.context_length = 1234;
5365 registry.register_user_model(mine);
5366 let mut publishers = signed_remote("shared/id", "publishers");
5367 publishers.context_length = 9999;
5368 assert_eq!(
5369 registry.replace_signed_catalog(
5370 vec![publishers],
5371 None,
5372 &HashSet::new(),
5373 &HashSet::new()
5374 ),
5375 0
5376 );
5377 assert_eq!(registry.get("shared/id").unwrap().context_length, 1234);
5378 registry.save_user_config().unwrap();
5379 let saved = std::fs::read_to_string(tmp.path().join(USER_MODELS_FILE)).unwrap();
5380 assert!(saved.contains("1234") && !saved.contains("9999"), "{saved}");
5381 assert!(registry.unregister_user_model("shared/id").is_some());
5382 }
5383
5384 #[test]
5385 fn a_withdrawn_row_something_depends_on_stays_deprecated_until_nothing_does() {
5386 let _environment = crate::openrouter::test_environment_scope();
5387 let tmp = TempDir::new().unwrap();
5388 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5389 tmp.path().to_path_buf(),
5390 tmp.path().join("models"),
5391 None,
5392 );
5393 let used = signed_remote("signed/used", "used");
5394 let idle = signed_remote("signed/idle", "idle");
5395 assert_eq!(
5396 registry.replace_signed_catalog(
5397 vec![used, idle],
5398 None,
5399 &HashSet::new(),
5400 &HashSet::new()
5401 ),
5402 2
5403 );
5404 let keep: HashSet<String> = ["signed/used".to_string()].into();
5405 assert_eq!(
5406 registry.replace_signed_catalog(vec![], None, &keep, &HashSet::new()),
5407 0
5408 );
5409 assert!(
5410 registry.get("signed/idle").is_none(),
5411 "nothing depended on it"
5412 );
5413 let kept = registry.get("signed/used").expect("a dependent keeps it");
5414 assert!(kept.deprecated, "kept, but never suggested");
5415 let used = signed_remote("signed/used", "used");
5417 assert_eq!(
5418 registry.replace_signed_catalog(vec![used], None, &keep, &HashSet::new()),
5419 1
5420 );
5421 assert!(!registry.get("signed/used").unwrap().deprecated);
5422 registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5424 assert!(registry.get("signed/used").is_none());
5425 }
5426
5427 #[test]
5428 fn a_kept_row_survives_a_restart_only_while_its_signed_catalog_verifies() {
5429 let _environment = crate::openrouter::test_environment_scope();
5430 let tmp = TempDir::new().unwrap();
5431 let open = |key: Option<&str>| {
5432 UnifiedRegistry::new_with_catalog_public_key(
5433 tmp.path().to_path_buf(),
5434 tmp.path().join("models"),
5435 key,
5436 )
5437 };
5438 let cache = crate::catalog::cache_path(tmp.path());
5439 let retained = crate::catalog::retained_path(tmp.path());
5440 let catalog = |models, version| {
5441 crate::catalog::signed_test_catalog(
5442 crate::catalog::CatalogDoc {
5443 revoked: Vec::new(),
5444 version,
5445 models,
5446 },
5447 21,
5448 )
5449 };
5450 let (v1, key) = catalog(
5451 vec![
5452 signed_remote("signed/used", "used"),
5453 signed_remote("signed/idle", "idle"),
5454 ],
5455 1,
5456 );
5457 crate::catalog::save_verified(&cache, &v1).unwrap();
5458 let mut running = open(Some(&key));
5459 assert!(running.get("signed/used").is_some());
5460
5461 let (v2, _) = catalog(vec![], 2);
5463 let keep: HashSet<String> = ["signed/used".to_string()].into();
5464 running.replace_signed_catalog(
5465 v2.clone().into_models(),
5466 Some(std::sync::Arc::new(v2.envelope())),
5467 &keep,
5468 &HashSet::new(),
5469 );
5470 crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
5471 crate::catalog::save_verified(&cache, &v2).unwrap();
5472
5473 let restarted = open(Some(&key));
5474 let kept = restarted.get("signed/used").expect("kept across a restart");
5475 assert!(kept.deprecated);
5476 assert!(restarted.get("signed/idle").is_none());
5477 let (_, other_key) = crate::catalog::signed_test_catalog(
5479 crate::catalog::CatalogDoc {
5480 revoked: Vec::new(),
5481 version: 9,
5482 models: vec![],
5483 },
5484 22,
5485 );
5486 assert!(open(Some(&other_key)).get("signed/used").is_none());
5487 let json = std::fs::read_to_string(&retained).unwrap();
5489 std::fs::write(&retained, json.replace("signed/idle", "signed/idlx")).unwrap();
5490 assert!(open(Some(&key)).get("signed/used").is_none());
5491 }
5492
5493 #[test]
5494 fn a_revoked_row_leaves_even_when_something_depends_on_it() {
5495 let _environment = crate::openrouter::test_environment_scope();
5496 let tmp = TempDir::new().unwrap();
5497 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5498 tmp.path().to_path_buf(),
5499 tmp.path().join("models"),
5500 None,
5501 );
5502 let none = HashSet::new();
5503 let bad: HashSet<String> = ["signed/bad".to_string()].into();
5504 registry.replace_signed_catalog(
5505 vec![signed_remote("signed/bad", "bad")],
5506 None,
5507 &none,
5508 &none,
5509 );
5510 registry.replace_signed_catalog(vec![], None, &bad, &none);
5512 assert!(registry.get("signed/bad").is_some());
5513 registry.replace_signed_catalog(vec![], None, &bad, &bad);
5515 assert!(registry.get("signed/bad").is_none());
5516 registry.replace_signed_catalog(
5518 vec![signed_remote("signed/bad", "bad")],
5519 None,
5520 &none,
5521 &bad,
5522 );
5523 assert!(registry.get("signed/bad").is_none());
5524 assert!(registry.retained_rows().is_empty());
5525
5526 let builtin = builtin_catalog()
5528 .into_iter()
5529 .find(|m| !m.deprecated)
5530 .expect("a current builtin");
5531 let revoked: HashSet<String> = [builtin.id.clone()].into();
5532 registry.replace_signed_catalog(vec![], None, &none, &revoked);
5533 assert!(registry.get(&builtin.id).unwrap().deprecated);
5534 registry.replace_signed_catalog(vec![], None, &none, &none);
5535 assert!(!registry.get(&builtin.id).unwrap().deprecated);
5536 }
5537
5538 #[test]
5539 fn a_restart_never_restores_a_row_the_current_catalog_revokes() {
5540 let _environment = crate::openrouter::test_environment_scope();
5541 let tmp = TempDir::new().unwrap();
5542 let catalog = |models, revoked, version| {
5543 crate::catalog::signed_test_catalog(
5544 crate::catalog::CatalogDoc {
5545 version,
5546 models,
5547 revoked,
5548 },
5549 23,
5550 )
5551 };
5552 let (v1, key) = catalog(vec![signed_remote("signed/kept", "kept")], vec![], 1);
5553 let mut running = UnifiedRegistry::new_with_catalog_public_key(
5554 tmp.path().to_path_buf(),
5555 tmp.path().join("models"),
5556 Some(&key),
5557 );
5558 running.replace_signed_catalog(
5559 v1.clone().into_models(),
5560 Some(std::sync::Arc::new(v1.envelope())),
5561 &HashSet::new(),
5562 &HashSet::new(),
5563 );
5564 let (v2, _) = catalog(vec![], vec![], 2);
5566 let keep: HashSet<String> = ["signed/kept".to_string()].into();
5567 running.replace_signed_catalog(
5568 vec![],
5569 Some(std::sync::Arc::new(v2.envelope())),
5570 &keep,
5571 &HashSet::new(),
5572 );
5573 let retained = crate::catalog::retained_path(tmp.path());
5574 crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
5575 let (v3, _) = catalog(vec![], vec!["signed/kept".to_string()], 3);
5577 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &v3).unwrap();
5578 let restarted = UnifiedRegistry::new_with_catalog_public_key(
5579 tmp.path().to_path_buf(),
5580 tmp.path().join("models"),
5581 Some(&key),
5582 );
5583 assert!(restarted.get("signed/kept").is_none());
5584 }
5585
5586 #[test]
5587 fn a_revoked_id_never_comes_back_from_the_discovery_cache() {
5588 let _environment = crate::openrouter::test_environment_scope();
5589 let tmp = TempDir::new().unwrap();
5590 let (catalog, key) = crate::catalog::signed_test_catalog(
5591 crate::catalog::CatalogDoc {
5592 version: 1,
5593 models: vec![],
5594 revoked: vec!["discovered/bad".to_string()],
5595 },
5596 24,
5597 );
5598 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &catalog).unwrap();
5599 crate::discovery::save_cache(
5602 &crate::discovery::cache_path(&tmp.path().join("models")),
5603 &[
5604 signed_remote("discovered/bad", "bad"),
5605 signed_remote("discovered/fine", "fine"),
5606 ],
5607 )
5608 .unwrap();
5609 let restarted = UnifiedRegistry::new_with_catalog_public_key(
5610 tmp.path().to_path_buf(),
5611 tmp.path().join("models"),
5612 Some(&key),
5613 );
5614 assert!(restarted.get("discovered/bad").is_none());
5615 assert!(restarted.get("discovered/fine").is_some());
5616
5617 let mut running = UnifiedRegistry::new_with_catalog_public_key(
5620 tmp.path().to_path_buf(),
5621 tmp.path().join("models"),
5622 None,
5623 );
5624 assert!(running.get("discovered/bad").is_some());
5625 let revoked: HashSet<String> = ["discovered/bad".to_string()].into();
5626 running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
5627 assert!(running.get("discovered/bad").is_none());
5628 assert!(running.get("discovered/fine").is_some());
5629
5630 running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5632 assert!(running.get("discovered/bad").is_some());
5633 assert!(!running.is_revoked("discovered/bad"));
5634
5635 running.register_user_model(signed_remote("discovered/bad", "mine"));
5638 running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
5639 assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5640 running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5641 assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5642 }
5643
5644 #[test]
5645 fn a_signed_overlay_updates_a_builtins_measurements_never_its_weights() {
5646 let _environment = crate::openrouter::test_environment_scope();
5647 let tmp = TempDir::new().unwrap();
5648 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5649 tmp.path().to_path_buf(),
5650 tmp.path().join("models"),
5651 None,
5652 );
5653 let compiled = builtin_catalog()
5654 .into_iter()
5655 .find(|m| m.public_benchmarks.iter().any(|b| b.name == "car-judged"))
5656 .expect("a scored builtin");
5657 let mut remeasured = compiled.clone();
5658 remeasured.public_benchmarks[0].score = 0.42;
5659 remeasured.public_benchmarks[0].runs = Some(3);
5660 remeasured.context_length = compiled.context_length + 1;
5661 remeasured.name = "renamed by the publisher".into();
5662 assert_eq!(
5663 registry.replace_signed_catalog(
5664 vec![remeasured.clone()],
5665 None,
5666 &HashSet::new(),
5667 &HashSet::new()
5668 ),
5669 1
5670 );
5671 let row = registry.get(&compiled.id).unwrap();
5672 assert_eq!(row.public_benchmarks[0].score, 0.42);
5673 assert_eq!(row.public_benchmarks[0].runs, Some(3));
5674 assert_eq!(
5675 row.context_length, compiled.context_length,
5676 "not an overlay field"
5677 );
5678 assert_eq!(row.name, compiled.name, "not an overlay field");
5679 registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5681 assert_eq!(
5682 registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5683 compiled.public_benchmarks[0].score
5684 );
5685 let mut repointed = remeasured;
5687 repointed.source = signed_remote("x/y", "y").source;
5688 assert_eq!(
5689 registry.replace_signed_catalog(
5690 vec![repointed],
5691 None,
5692 &HashSet::new(),
5693 &HashSet::new()
5694 ),
5695 0
5696 );
5697 assert_eq!(
5698 registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5699 compiled.public_benchmarks[0].score
5700 );
5701 }
5702
5703 #[test]
5704 fn user_config_persistence_excludes_signed_rows_and_keeps_builtin_tagged_user_rows() {
5705 let _environment = crate::openrouter::test_environment_scope();
5710 let tmp = TempDir::new().unwrap();
5711 let models_dir = tmp.path().join("models");
5712 let config_path = tmp.path().join("models.json");
5713
5714 let signed = test_generate_schema(
5715 "signed/catalog-only",
5716 "signed-catalog-only",
5717 ModelSource::RemoteApi {
5718 endpoint: "https://catalog.example/v1".into(),
5719 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5720 api_key_envs: vec![],
5721 api_version: None,
5722 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5723 },
5724 );
5725 assert!(!signed.tags.iter().any(|tag| tag == "builtin"));
5726 let (verified, public_key) = crate::catalog::signed_test_catalog(
5727 crate::catalog::CatalogDoc {
5728 revoked: Vec::new(),
5729 version: 81,
5730 models: vec![signed],
5731 },
5732 81,
5733 );
5734 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
5735
5736 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5737 tmp.path().to_path_buf(),
5738 models_dir.clone(),
5739 Some(public_key.as_str()),
5740 );
5741 let mut user = test_generate_schema(
5742 "user/builtin-tagged",
5743 "user-builtin-tagged",
5744 ModelSource::RemoteApi {
5745 endpoint: "https://user.example/v1".into(),
5746 api_key_env: "USER_MODEL_TEST_KEY".into(),
5747 api_key_envs: vec![],
5748 api_version: None,
5749 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5750 },
5751 );
5752 user.tags.push("builtin".into());
5753 registry.register_user_model(user);
5754 registry.save_user_config().unwrap();
5755
5756 let saved: Vec<ModelSchema> =
5757 serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
5758 assert_eq!(
5759 saved
5760 .iter()
5761 .map(|model| model.id.as_str())
5762 .collect::<Vec<_>>(),
5763 vec!["user/builtin-tagged"],
5764 "models.json must contain only explicitly user-registered rows"
5765 );
5766 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
5767
5768 let mut restarted = UnifiedRegistry::new_with_catalog_public_key(
5769 tmp.path().to_path_buf(),
5770 models_dir,
5771 Some(public_key.as_str()),
5772 );
5773 assert_eq!(
5774 restarted.get("signed/catalog-only").unwrap().trust_tier,
5775 crate::schema::TrustTier::Curated,
5776 "user persistence must not demote an unrelated signed catalog row"
5777 );
5778 assert_eq!(
5779 restarted.get("user/builtin-tagged").unwrap().trust_tier,
5780 crate::schema::TrustTier::Community
5781 );
5782 let mut signed_shadow = restarted.get("signed/catalog-only").unwrap().clone();
5783 signed_shadow.name = "user-shadow-of-signed-row".into();
5784 restarted.register_user_model(signed_shadow);
5785 assert_eq!(
5786 restarted.get("signed/catalog-only").unwrap().name,
5787 "signed-catalog-only",
5788 "a user row must not shadow a signature-verified project exact id"
5789 );
5790 }
5791
5792 #[test]
5793 fn empty_user_config_save_clears_stale_rows() {
5794 let tmp = TempDir::new().unwrap();
5795 let models_dir = tmp.path().join("models");
5796 let config_path = tmp.path().join("models.json");
5797 let stale = test_generate_schema(
5798 "user/stale",
5799 "stale",
5800 ModelSource::RemoteApi {
5801 endpoint: "https://stale.example/v1".into(),
5802 api_key_env: "STALE_USER_MODEL_TEST_KEY".into(),
5803 api_key_envs: vec![],
5804 api_version: None,
5805 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5806 },
5807 );
5808 std::fs::write(
5809 &config_path,
5810 serde_json::to_vec_pretty(&vec![stale]).unwrap(),
5811 )
5812 .unwrap();
5813
5814 UnifiedRegistry::new_empty(models_dir)
5815 .save_user_config()
5816 .unwrap();
5817
5818 let saved: Vec<ModelSchema> =
5819 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5820 assert!(
5821 saved.is_empty(),
5822 "saving an empty user set must overwrite stale models.json rows"
5823 );
5824 }
5825
5826 #[test]
5827 fn unregister_then_save_removes_the_user_row_from_disk() {
5828 let tmp = TempDir::new().unwrap();
5829 let models_dir = tmp.path().join("models");
5830 let config_path = tmp.path().join("models.json");
5831 let mut registry = UnifiedRegistry::new_empty(models_dir);
5832 registry.register_project_model(test_generate_schema(
5833 "signed/not-user-removable",
5834 "not-user-removable",
5835 ModelSource::RemoteApi {
5836 endpoint: "https://catalog.example/v1".into(),
5837 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5838 api_key_envs: vec![],
5839 api_version: None,
5840 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5841 },
5842 ));
5843 assert!(
5844 registry
5845 .unregister_user_model("signed/not-user-removable")
5846 .is_none(),
5847 "the user boundary cannot unregister an untracked catalog row"
5848 );
5849 assert!(registry.get("signed/not-user-removable").is_some());
5850 registry.register_user_model(test_generate_schema(
5851 "user/removable",
5852 "removable",
5853 ModelSource::RemoteApi {
5854 endpoint: "https://user.example/v1".into(),
5855 api_key_env: "REMOVABLE_USER_MODEL_TEST_KEY".into(),
5856 api_key_envs: vec![],
5857 api_version: None,
5858 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5859 },
5860 ));
5861 registry.save_user_config().unwrap();
5862 assert!(registry.unregister_user_model("user/removable").is_some());
5863 registry.save_user_config().unwrap();
5864
5865 let saved: Vec<ModelSchema> =
5866 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5867 assert!(saved.is_empty());
5868 }
5869
5870 #[test]
5882 fn a_registration_written_under_car_home_is_the_file_the_registry_reads() {
5883 let _environment = crate::openrouter::test_environment_scope();
5884 let prior = std::env::var_os(car_home::ENV_VAR);
5885
5886 let state_root = TempDir::new().unwrap();
5887 let weights = TempDir::new().unwrap();
5890 let models_dir = weights.path().join("models");
5891 std::fs::create_dir_all(&models_dir).unwrap();
5892
5893 unsafe { std::env::set_var(car_home::ENV_VAR, state_root.path()) };
5894
5895 let write_path = user_config_path().expect("CAR_HOME must resolve a models.json path");
5897 assert_eq!(write_path, state_root.path().join(USER_MODELS_FILE));
5898 let registered = test_generate_schema(
5899 "user/relocated-daemon-model",
5900 "relocated-daemon-model",
5901 ModelSource::RemoteApi {
5902 endpoint: "https://relocated.example/v1".into(),
5903 api_key_env: "RELOCATED_DAEMON_MODEL_TEST_KEY".into(),
5904 api_key_envs: vec![],
5905 api_version: None,
5906 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5907 },
5908 );
5909 std::fs::write(
5910 &write_path,
5911 serde_json::to_vec_pretty(&vec![registered]).unwrap(),
5912 )
5913 .unwrap();
5914
5915 let registry = UnifiedRegistry::new(models_dir.clone());
5917
5918 match prior {
5919 Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
5920 None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
5921 }
5922
5923 assert!(
5924 registry.get("user/relocated-daemon-model").is_some(),
5925 "the registry must load the models.json that `models.register` wrote; \
5926 it looked at {} instead",
5927 registry.user_config_path.display(),
5928 );
5929 assert_eq!(registry.user_config_path, write_path);
5930 assert!(
5931 !weights.path().join(USER_MODELS_FILE).exists(),
5932 "nothing may be written beside the shared weights cache",
5933 );
5934 }
5935
5936 #[test]
5937 fn ready_without_download_is_strict_for_local_model_files() {
5938 let tmp = TempDir::new().unwrap();
5939 let models = tmp.path().join("models");
5940 let mut reg = UnifiedRegistry::new_empty(models.clone());
5941 reg.register(test_generate_schema(
5942 "local/test",
5943 "TestLocal",
5944 ModelSource::Local {
5945 hf_repo: "example/repo".into(),
5946 hf_filename: "model.gguf".into(),
5947 tokenizer_repo: "example/repo".into(),
5948 },
5949 ));
5950
5951 assert_eq!(reg.ready_without_download("local/test"), Some(false));
5952
5953 let dir = models.join("TestLocal");
5954 std::fs::create_dir_all(&dir).unwrap();
5955 std::fs::write(dir.join("model.gguf"), b"weights").unwrap();
5956 assert_eq!(
5957 reg.ready_without_download("local/test"),
5958 Some(false),
5959 "tokenizer is required too"
5960 );
5961 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
5962 assert_eq!(reg.ready_without_download("local/test"), Some(true));
5963 }
5964
5965 #[test]
5966 fn ready_without_download_rejects_mlx_config_only_stub() {
5967 let tmp = TempDir::new().unwrap();
5968 let models = tmp.path().join("models");
5969 let mut reg = UnifiedRegistry::new_empty(models.clone());
5970 reg.register(test_generate_schema(
5971 "mlx/test",
5972 "TestMlx",
5973 ModelSource::Mlx {
5974 hf_repo: "example/repo".into(),
5975 hf_weight_file: None,
5976 },
5977 ));
5978
5979 let dir = models.join("TestMlx");
5980 std::fs::create_dir_all(&dir).unwrap();
5981 std::fs::write(dir.join("config.json"), "{}").unwrap();
5982 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
5983 assert_eq!(
5984 reg.ready_without_download("mlx/test"),
5985 Some(false),
5986 "config/tokenizer stubs must not start assistant inference"
5987 );
5988
5989 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
5990 assert_eq!(reg.ready_without_download("mlx/test"), Some(true));
5991 }
5992
5993 fn write_mlx_dir(root: &Path, name: &str, model_type: &str) {
5994 let dir = root.join(name);
5995 std::fs::create_dir_all(&dir).unwrap();
5996 std::fs::write(
5997 dir.join("config.json"),
5998 serde_json::json!({
5999 "model_type": model_type,
6000 "max_position_embeddings": 40_960,
6001 "quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" },
6002 })
6003 .to_string(),
6004 )
6005 .unwrap();
6006 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
6007 }
6008
6009 struct ScopedEnvVar {
6010 name: &'static str,
6011 previous: Option<std::ffi::OsString>,
6012 }
6013
6014 impl ScopedEnvVar {
6015 fn set(name: &'static str, value: &Path) -> Self {
6016 let previous = std::env::var_os(name);
6017 unsafe { std::env::set_var(name, value) };
6020 Self { name, previous }
6021 }
6022
6023 fn unset(name: &'static str) -> Self {
6024 let previous = std::env::var_os(name);
6025 unsafe { std::env::remove_var(name) };
6027 Self { name, previous }
6028 }
6029 }
6030
6031 impl Drop for ScopedEnvVar {
6032 fn drop(&mut self) {
6033 unsafe {
6036 match self.previous.take() {
6037 Some(value) => std::env::set_var(self.name, value),
6038 None => std::env::remove_var(self.name),
6039 }
6040 }
6041 }
6042 }
6043
6044 #[derive(Default)]
6045 struct AcquisitionRecorder {
6046 events: std::sync::Mutex<Vec<DownloadEvent>>,
6047 replace_dir_on_started: std::sync::Mutex<Option<PathBuf>>,
6048 }
6049
6050 impl AcquisitionRecorder {
6051 fn replacing_dir_on_started(path: PathBuf) -> Self {
6052 Self {
6053 events: std::sync::Mutex::new(Vec::new()),
6054 replace_dir_on_started: std::sync::Mutex::new(Some(path)),
6055 }
6056 }
6057
6058 fn events(&self) -> Vec<DownloadEvent> {
6059 self.events.lock().unwrap().clone()
6060 }
6061 }
6062
6063 impl crate::download::DownloadProgress for AcquisitionRecorder {
6064 fn on_event(&self, event: &DownloadEvent) {
6065 self.events.lock().unwrap().push(event.clone());
6066 if matches!(event, DownloadEvent::Started { .. }) {
6067 if let Some(path) = self.replace_dir_on_started.lock().unwrap().take() {
6068 std::fs::remove_dir_all(&path).unwrap();
6069 std::fs::write(path, b"make create_dir_all fail before any network access")
6070 .unwrap();
6071 }
6072 }
6073 }
6074 }
6075
6076 fn started_count(events: &[DownloadEvent]) -> usize {
6077 events
6078 .iter()
6079 .filter(|event| matches!(event, DownloadEvent::Started { .. }))
6080 .count()
6081 }
6082
6083 fn mlx_schema(id: &str, name: &str, hf_repo: &str) -> ModelSchema {
6084 test_generate_schema(
6085 id,
6086 name,
6087 ModelSource::Mlx {
6088 hf_repo: hf_repo.into(),
6089 hf_weight_file: None,
6090 },
6091 )
6092 }
6093
6094 #[test]
6095 fn installing_a_fetched_file_replaces_only_unusable_destinations() {
6096 let tmp = TempDir::new().unwrap();
6097 let src = tmp.path().join("fetched");
6098 std::fs::write(&src, b"fetched bytes").unwrap();
6099
6100 let missing = tmp.path().join("missing");
6101 install_fetched_file(&src, &missing).unwrap();
6102 assert_eq!(std::fs::read(&missing).unwrap(), b"fetched bytes");
6103
6104 let usable = tmp.path().join("usable");
6105 std::fs::write(&usable, b"keep these bytes").unwrap();
6106 let usable_before = std::fs::symlink_metadata(&usable).unwrap();
6107 let modified_before = usable_before.modified().unwrap();
6108 #[cfg(unix)]
6109 let inode_before = {
6110 use std::os::unix::fs::MetadataExt;
6111 usable_before.ino()
6112 };
6113 install_fetched_file(&src, &usable).unwrap();
6114 let usable_after = std::fs::symlink_metadata(&usable).unwrap();
6115 assert_eq!(std::fs::read(&usable).unwrap(), b"keep these bytes");
6116 assert_eq!(usable_after.modified().unwrap(), modified_before);
6117 #[cfg(unix)]
6118 {
6119 use std::os::unix::fs::MetadataExt;
6120 assert_eq!(usable_after.ino(), inode_before);
6121 }
6122
6123 let zero_byte = tmp.path().join("zero-byte");
6124 std::fs::write(&zero_byte, b"").unwrap();
6125 install_fetched_file(&src, &zero_byte).unwrap();
6126 assert_eq!(std::fs::read(&zero_byte).unwrap(), b"fetched bytes");
6127
6128 #[cfg(unix)]
6129 {
6130 let dangling = tmp.path().join("dangling");
6131 std::os::unix::fs::symlink(tmp.path().join("absent"), &dangling).unwrap();
6132 assert!(std::fs::symlink_metadata(&dangling).unwrap().is_symlink());
6133 install_fetched_file(&src, &dangling).unwrap();
6134 assert_eq!(std::fs::read(&dangling).unwrap(), b"fetched bytes");
6135 }
6136 }
6137
6138 #[cfg(unix)]
6141 #[tokio::test]
6142 async fn a_zero_byte_flux_auxiliary_file_is_not_accepted_as_present() {
6143 let _environment = crate::openrouter::test_environment_scope_async().await;
6144 let tmp = TempDir::new().unwrap();
6145 let home = tmp.path().join("home");
6146 let _home = ScopedEnvVar::set("HOME", &home);
6147 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6148 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6149 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6150
6151 let cached_auxiliary = tmp
6155 .path()
6156 .join("hf-home/hub")
6157 .join("models--Freepik--flux.1-lite-8B")
6158 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
6159 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
6160 std::fs::write(&cached_auxiliary, b"repaired tokenizer").unwrap();
6161 let refs = tmp
6162 .path()
6163 .join("hf-home/hub/models--Freepik--flux.1-lite-8B/refs");
6164 std::fs::create_dir_all(&refs).unwrap();
6165 std::fs::write(refs.join("main"), b"fixture").unwrap();
6166
6167 let models = tmp.path().join("models");
6168 let name = "Flux-1.lite-8B-MLX-Q4";
6169 write_mlx_dir(&models, name, "flux");
6170 let auxiliary = models.join(name).join("tokenizer_2/tokenizer.json");
6171 std::fs::create_dir_all(auxiliary.parent().unwrap()).unwrap();
6172 std::fs::write(&auxiliary, b"").unwrap();
6173 let mut reg = UnifiedRegistry::new_empty(models.clone());
6174 reg.register(mlx_schema(
6175 "mlx/flux-zero-byte-aux",
6176 name,
6177 "mlx-community/Flux-1.lite-8B-MLX-Q4",
6178 ));
6179 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6180 let sink = ProgressSink::new(recorder.clone());
6181
6182 let result = reg
6183 .acquire_and_ensure("mlx/flux-zero-byte-aux", &sink, false, None)
6184 .await;
6185 let events = recorder.events();
6186 assert!(matches!(
6187 events.first(),
6188 Some(DownloadEvent::Started { .. })
6189 ));
6190 assert_eq!(started_count(&events), 1);
6191 assert!(
6192 result.is_err() || crate::download::cache_file_usable(&auxiliary),
6193 "acquisition must replace the zero-byte auxiliary file or report failure"
6194 );
6195 if result.is_ok() {
6196 assert_eq!(std::fs::read(auxiliary).unwrap(), b"repaired tokenizer");
6197 }
6198 assert!(
6201 !tmp.path()
6202 .join("model-management")
6203 .join(crate::retire::PROVENANCE_FILE)
6204 .exists(),
6205 "reusing a cached file must not record it as a CAR download"
6206 );
6207 }
6208
6209 #[cfg(unix)]
6212 #[tokio::test]
6213 async fn managed_flux_missing_auxiliary_does_not_fall_through_to_hf_snapshot() {
6214 let _environment = crate::openrouter::test_environment_scope_async().await;
6215 let tmp = TempDir::new().unwrap();
6216 let home = tmp.path().join("home");
6217 let hf_home = tmp.path().join("hf-home");
6218 let _home = ScopedEnvVar::set("HOME", &home);
6219 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
6220 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6221 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6222
6223 let cached_auxiliary = hf_home
6227 .join("hub")
6228 .join("models--Freepik--flux.1-lite-8B")
6229 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
6230 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
6231 std::fs::write(&cached_auxiliary, b"managed repair tokenizer").unwrap();
6232 let refs = hf_home.join("hub/models--Freepik--flux.1-lite-8B/refs");
6233 std::fs::create_dir_all(&refs).unwrap();
6234 std::fs::write(refs.join("main"), b"fixture").unwrap();
6235
6236 let models = tmp.path().join("models");
6237 let name = "Flux-1.lite-8B-MLX-Q4";
6238 let managed = models.join(name);
6239 write_mlx_dir(&models, name, "flux");
6240 let managed_auxiliary = managed.join("tokenizer_2/tokenizer.json");
6241
6242 let snapshot = hf_home
6245 .join("hub/models--mlx-community--Flux-1.lite-8B-MLX-Q4")
6246 .join("snapshots/fixture");
6247 write_complete_mlx_snapshot(&snapshot);
6248 let snapshot_auxiliary = snapshot.join("tokenizer_2/tokenizer.json");
6249 std::fs::create_dir_all(snapshot_auxiliary.parent().unwrap()).unwrap();
6250 std::fs::write(&snapshot_auxiliary, b"snapshot tokenizer").unwrap();
6251
6252 let id = "mlx/flux-managed-precedence";
6253 let mut reg = UnifiedRegistry::new_empty(models);
6254 reg.register(mlx_schema(id, name, "mlx-community/Flux-1.lite-8B-MLX-Q4"));
6255 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6256 let sink = ProgressSink::new(recorder.clone());
6257
6258 let path = reg
6259 .acquire_and_ensure(id, &sink, false, None)
6260 .await
6261 .unwrap();
6262
6263 assert_eq!(path, managed);
6264 assert_eq!(
6265 std::fs::read(managed_auxiliary).unwrap(),
6266 b"managed repair tokenizer"
6267 );
6268 let events = recorder.events();
6269 assert!(matches!(
6270 events.first(),
6271 Some(DownloadEvent::Started { .. })
6272 ));
6273 assert_eq!(started_count(&events), 1);
6274 }
6275
6276 #[tokio::test]
6277 async fn reusing_a_complete_managed_mlx_dir_emits_no_acquisition_lifecycle() {
6278 let _environment = crate::openrouter::test_environment_scope_async().await;
6279 let tmp = TempDir::new().unwrap();
6280 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6281 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6282 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6283 let models = tmp.path().join("models");
6284 let mut reg = UnifiedRegistry::new_empty(models.clone());
6285 reg.register(mlx_schema(
6286 "mlx/reuse-complete",
6287 "Reuse-Complete-MLX",
6288 "example/reuse-complete",
6289 ));
6290 write_mlx_dir(&models, "Reuse-Complete-MLX", "qwen3");
6291 std::fs::write(
6292 models.join("Reuse-Complete-MLX").join("tokenizer.json"),
6293 b"{}",
6294 )
6295 .unwrap();
6296 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6297 let sink = ProgressSink::new(recorder.clone());
6298
6299 let path = reg
6300 .ensure_local_with_progress("mlx/reuse-complete", &sink)
6301 .await
6302 .unwrap();
6303
6304 assert_eq!(path, models.join("Reuse-Complete-MLX"));
6305 assert!(recorder.events().is_empty());
6306 }
6307
6308 #[tokio::test]
6309 async fn reusing_a_managed_mlx_dir_without_tokenizer_json_emits_no_lifecycle() {
6310 let _environment = crate::openrouter::test_environment_scope_async().await;
6311 let tmp = TempDir::new().unwrap();
6312 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6313 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6314 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6315 let models = tmp.path().join("models");
6316 let mut reg = UnifiedRegistry::new_empty(models.clone());
6317 reg.register(mlx_schema(
6318 "mlx/reuse-no-tokenizer",
6319 "Reuse-No-Tokenizer-MLX",
6320 "example/reuse-no-tokenizer",
6321 ));
6322 write_mlx_dir(&models, "Reuse-No-Tokenizer-MLX", "qwen3");
6323 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6324 let sink = ProgressSink::new(recorder.clone());
6325
6326 let path = reg
6327 .ensure_local_with_progress("mlx/reuse-no-tokenizer", &sink)
6328 .await
6329 .unwrap();
6330
6331 assert_eq!(path, models.join("Reuse-No-Tokenizer-MLX"));
6332 assert!(recorder.events().is_empty());
6333 }
6334
6335 #[tokio::test]
6336 async fn reusing_a_complete_hf_snapshot_emits_no_acquisition_lifecycle() {
6337 let _environment = crate::openrouter::test_environment_scope_async().await;
6338 let tmp = TempDir::new().unwrap();
6339 let hf_home = tmp.path().join("hf-home");
6340 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
6341 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6342 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6343 let models = tmp.path().join("models");
6344 let repo = "example/reuse-hf-snapshot";
6345 let snapshot = hf_home
6346 .join("hub")
6347 .join("models--example--reuse-hf-snapshot")
6348 .join("snapshots")
6349 .join("fixture");
6350 write_complete_mlx_snapshot(&snapshot);
6351 let mut reg = UnifiedRegistry::new_empty(models);
6352 reg.register(mlx_schema("mlx/reuse-hf", "Reuse-HF-MLX", repo));
6353 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6354 let sink = ProgressSink::new(recorder.clone());
6355
6356 let path = reg
6357 .ensure_local_with_progress("mlx/reuse-hf", &sink)
6358 .await
6359 .unwrap();
6360
6361 assert_eq!(path, snapshot);
6362 assert!(recorder.events().is_empty());
6363 }
6364
6365 #[tokio::test]
6366 async fn a_zero_byte_managed_config_json_starts_acquisition() {
6367 let _environment = crate::openrouter::test_environment_scope_async().await;
6368 let tmp = TempDir::new().unwrap();
6369 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6370 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6371 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6372 let models = tmp.path().join("models");
6373 let name = "Zero-Byte-Config-MLX";
6374 write_mlx_dir(&models, name, "qwen3");
6375 let model_dir = models.join(name);
6376 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
6377 std::fs::write(model_dir.join("config.json"), b"").unwrap();
6378 let mut reg = UnifiedRegistry::new_empty(models);
6379 reg.register(mlx_schema(
6380 "mlx/zero-byte-config",
6381 name,
6382 "example/zero-byte-config",
6383 ));
6384 let recorder =
6385 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
6386 let sink = ProgressSink::new(recorder.clone());
6387
6388 let result = reg
6389 .ensure_local_with_progress("mlx/zero-byte-config", &sink)
6390 .await;
6391
6392 let events = recorder.events();
6393 assert!(
6394 matches!(events.first(), Some(DownloadEvent::Started { .. })),
6395 "weights beside a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
6396 );
6397 assert_eq!(started_count(&events), 1);
6398 assert!(result.is_err());
6399 }
6400
6401 #[tokio::test]
6402 async fn a_zero_byte_hf_snapshot_config_json_starts_acquisition() {
6403 let _environment = crate::openrouter::test_environment_scope_async().await;
6404 let tmp = TempDir::new().unwrap();
6405 let hf_home = tmp.path().join("hf-home");
6406 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
6407 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6408 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6409 let models = tmp.path().join("models");
6410 let name = "Zero-Byte-Snapshot-Config-MLX";
6411 let repo = "example/zero-byte-snapshot-config";
6412 let snapshot = hf_home
6413 .join("hub")
6414 .join("models--example--zero-byte-snapshot-config")
6415 .join("snapshots")
6416 .join("fixture");
6417 write_complete_mlx_snapshot(&snapshot);
6418 std::fs::write(snapshot.join("config.json"), b"").unwrap();
6419 let model_dir = models.join(name);
6422 std::fs::create_dir_all(&model_dir).unwrap();
6423 let mut reg = UnifiedRegistry::new_empty(models);
6424 reg.register(mlx_schema("mlx/zero-byte-snapshot-config", name, repo));
6425 let recorder =
6426 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
6427 let sink = ProgressSink::new(recorder.clone());
6428
6429 let result = reg
6430 .ensure_local_with_progress("mlx/zero-byte-snapshot-config", &sink)
6431 .await;
6432
6433 let events = recorder.events();
6434 assert!(
6435 matches!(events.first(), Some(DownloadEvent::Started { .. })),
6436 "a snapshot with a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
6437 );
6438 assert_eq!(started_count(&events), 1);
6439 assert!(result.is_err());
6440 }
6441
6442 #[cfg(unix)]
6443 #[tokio::test]
6444 async fn an_oversized_installed_model_is_reused_below_the_disk_threshold() {
6445 let _environment = crate::openrouter::test_environment_scope_async().await;
6446 let tmp = TempDir::new().unwrap();
6447 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6448 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6449 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6450 let models = tmp.path().join("models");
6451 let mut schema = mlx_schema(
6452 "mlx/reuse-oversized",
6453 "Reuse-Oversized-MLX",
6454 "example/reuse-oversized",
6455 );
6456 schema.cost.size_mb = Some(1_000_000_000);
6459 let mut reg = UnifiedRegistry::new_empty(models.clone());
6460 reg.register(schema);
6461 write_mlx_dir(&models, "Reuse-Oversized-MLX", "qwen3");
6462 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6463 let sink = ProgressSink::new(recorder.clone());
6464
6465 let path = reg
6466 .ensure_local_with_progress("mlx/reuse-oversized", &sink)
6467 .await
6468 .unwrap();
6469
6470 assert_eq!(path, models.join("Reuse-Oversized-MLX"));
6471 assert!(recorder.events().is_empty());
6472 }
6473
6474 #[tokio::test]
6475 async fn a_missing_shard_starts_acquisition_exactly_once_after_the_preflight() {
6476 let _environment = crate::openrouter::test_environment_scope_async().await;
6477 let tmp = TempDir::new().unwrap();
6478 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6479 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6480 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6481 let models = tmp.path().join("models");
6482 let model_dir = models.join("Missing-Shard-MLX");
6483 std::fs::create_dir_all(&model_dir).unwrap();
6484 std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
6485 std::fs::write(
6486 model_dir.join("model.safetensors.index.json"),
6487 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
6488 )
6489 .unwrap();
6490 std::fs::write(model_dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
6491 let mut reg = UnifiedRegistry::new_empty(models);
6492 reg.register(mlx_schema(
6493 "mlx/missing-shard-lifecycle",
6494 "Missing-Shard-MLX",
6495 "example/missing-shard",
6496 ));
6497 let recorder =
6498 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
6499 let sink = ProgressSink::new(recorder.clone());
6500
6501 assert!(reg
6502 .ensure_local_with_progress("mlx/missing-shard-lifecycle", &sink)
6503 .await
6504 .is_err());
6505 let events = recorder.events();
6506 assert!(matches!(
6507 events.first(),
6508 Some(DownloadEvent::Started { .. })
6509 ));
6510 assert_eq!(started_count(&events), 1);
6511 }
6512
6513 #[tokio::test]
6514 async fn a_flux_dir_missing_its_auxiliary_tokenizer_keeps_the_lifecycle() {
6515 let _environment = crate::openrouter::test_environment_scope_async().await;
6516 let tmp = TempDir::new().unwrap();
6517 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6518 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6519 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6520 let models = tmp.path().join("models");
6521 let name = "Flux-1.lite-8B-MLX-Q4";
6522 write_mlx_dir(&models, name, "flux");
6523 std::fs::write(models.join(name).join("tokenizer_2"), b"not a directory").unwrap();
6524 let mut reg = UnifiedRegistry::new_empty(models);
6525 reg.register(mlx_schema(
6526 "mlx/flux-missing-aux",
6527 name,
6528 "mlx-community/Flux-1.lite-8B-MLX-Q4",
6529 ));
6530 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6531 let sink = ProgressSink::new(recorder.clone());
6532
6533 assert!(reg
6534 .ensure_local_with_progress("mlx/flux-missing-aux", &sink)
6535 .await
6536 .is_err());
6537 let events = recorder.events();
6538 assert!(matches!(
6539 events.first(),
6540 Some(DownloadEvent::Started { .. })
6541 ));
6542 assert_eq!(started_count(&events), 1);
6543 }
6544
6545 #[tokio::test]
6546 async fn force_bypasses_reuse() {
6547 let _environment = crate::openrouter::test_environment_scope_async().await;
6548 let tmp = TempDir::new().unwrap();
6549 let models = tmp.path().join("models");
6550 let name = "Force-Local";
6551 let model_dir = models.join(name);
6552 std::fs::create_dir_all(&model_dir).unwrap();
6553 std::fs::write(model_dir.join("model.gguf"), b"weights").unwrap();
6554 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
6555 let mut reg = UnifiedRegistry::new_empty(models);
6556 reg.register(test_generate_schema(
6557 "local/force-lifecycle",
6558 name,
6559 ModelSource::Local {
6560 hf_repo: String::new(),
6561 hf_filename: "model.gguf".into(),
6562 tokenizer_repo: String::new(),
6563 },
6564 ));
6565 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6566 let sink = ProgressSink::new(recorder.clone());
6567
6568 assert!(reg
6569 .acquire_and_ensure("local/force-lifecycle", &sink, true, None)
6570 .await
6571 .is_err());
6572 let events = recorder.events();
6573 assert!(matches!(
6574 events.first(),
6575 Some(DownloadEvent::Started { .. })
6576 ));
6577 assert_eq!(started_count(&events), 1);
6578 }
6579
6580 #[tokio::test]
6581 async fn staged_pulls_keep_the_lifecycle() {
6582 let _environment = crate::openrouter::test_environment_scope_async().await;
6583 let tmp = TempDir::new().unwrap();
6584 let models = tmp.path().join("models");
6585 let canonical = models.join("Staged-Local");
6586 std::fs::create_dir_all(&canonical).unwrap();
6587 std::fs::write(canonical.join("model.gguf"), b"canonical weights").unwrap();
6588 std::fs::write(canonical.join("tokenizer.json"), b"{}").unwrap();
6589 let mut reg = UnifiedRegistry::new_empty(models);
6590 reg.register(test_generate_schema(
6591 "local/staged-lifecycle",
6592 "Staged-Local",
6593 ModelSource::Local {
6594 hf_repo: String::new(),
6595 hf_filename: "model.gguf".into(),
6596 tokenizer_repo: String::new(),
6597 },
6598 ));
6599 let staging = tmp.path().join("staging");
6600 std::fs::create_dir_all(&staging).unwrap();
6601 std::fs::write(staging.join("model.gguf"), b"weights").unwrap();
6602 std::fs::write(staging.join("tokenizer.json"), b"{}").unwrap();
6603 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6604 let sink = ProgressSink::new(recorder.clone());
6605
6606 let path = reg
6607 .acquire_and_ensure("local/staged-lifecycle", &sink, false, Some(&staging))
6608 .await
6609 .unwrap();
6610 assert_eq!(path, staging);
6611 let events = recorder.events();
6612 assert!(matches!(
6613 events.first(),
6614 Some(DownloadEvent::Started { .. })
6615 ));
6616 assert_eq!(started_count(&events), 1);
6617 }
6618
6619 #[test]
6620 fn synthesize_local_schema_classifies_by_name_and_arch() {
6621 let tmp = TempDir::new().unwrap();
6622 let root = tmp.path();
6623
6624 write_mlx_dir(root, "MyCustom-Qwen3-7B", "qwen3");
6625 let gen = synthesize_local_schema("MyCustom-Qwen3-7B", &root.join("MyCustom-Qwen3-7B"))
6626 .expect("text LLM should be recognized");
6627 assert_eq!(
6628 gen.capabilities,
6629 vec![
6630 ModelCapability::Generate,
6631 ModelCapability::Code,
6632 ModelCapability::Reasoning
6633 ]
6634 );
6635 assert_eq!(gen.context_length, 40_960);
6636 assert_eq!(gen.provider, "local");
6637 assert!(matches!(gen.source, ModelSource::Mlx { .. }));
6638 assert_eq!(gen.family, "local");
6644 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6645 assert!(!crate::InferenceEngine::supports_native_mlx(&gen));
6646
6647 write_mlx_dir(root, "Some-Embedding-0.6B", "qwen3");
6648 let emb = synthesize_local_schema("Some-Embedding-0.6B", &root.join("Some-Embedding-0.6B"))
6649 .expect("embedding model recognized");
6650 assert_eq!(emb.capabilities, vec![ModelCapability::Embed]);
6651
6652 write_mlx_dir(root, "Mystery-Net", "some_unknown_arch");
6654 assert!(synthesize_local_schema("Mystery-Net", &root.join("Mystery-Net")).is_none());
6655
6656 write_mlx_dir(root, "silero-vad-v6-mlx", "qwen3");
6658 assert!(
6659 synthesize_local_schema("silero-vad-v6-mlx", &root.join("silero-vad-v6-mlx")).is_none()
6660 );
6661
6662 std::fs::create_dir_all(root.join("empty")).unwrap();
6664 assert!(synthesize_local_schema("empty", &root.join("empty")).is_none());
6665 }
6666
6667 #[test]
6673 fn a_scanned_gguf_directory_diagnoses_instead_of_downloading_from_nowhere() {
6674 let _environment = crate::openrouter::test_environment_scope();
6675 let tmp = TempDir::new().unwrap();
6676 let models = tmp.path().join("models");
6677 let dir = models.join("Dropped-In-Llama");
6678 std::fs::create_dir_all(&dir).unwrap();
6679 std::fs::write(dir.join("Llama-3-8B-Q4_K_M.gguf"), b"weights").unwrap();
6681
6682 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6685 let err = tokio::runtime::Runtime::new()
6686 .unwrap()
6687 .block_on(reg.ensure_local("Dropped-In-Llama"))
6688 .expect_err("an unloadable layout must not report success");
6689 let err = err.to_string();
6690
6691 assert!(err.contains("model.gguf"), "must name what it reads: {err}");
6692 assert!(
6693 err.contains("tokenizer.json"),
6694 "must name the missing tokenizer too: {err}"
6695 );
6696 assert!(
6697 !err.contains("huggingface.co//"),
6698 "must not have tried to fetch from an empty repo: {err}"
6699 );
6700 }
6701
6702 #[test]
6703 fn discovery_registers_uncatalogued_local_model() {
6704 let _environment = crate::openrouter::test_environment_scope();
6709 let tmp = TempDir::new().unwrap();
6710 let models = tmp.path().join("models");
6711 std::fs::create_dir_all(&models).unwrap();
6712 write_mlx_dir(&models, "Totally-Custom-Llama-3B", "llama");
6713
6714 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6715 let found = reg
6716 .list()
6717 .into_iter()
6718 .find(|m| m.name == "Totally-Custom-Llama-3B");
6719 assert!(
6720 found.is_some(),
6721 "uncatalogued on-disk model should be registered"
6722 );
6723 assert!(found.unwrap().tags.iter().any(|t| t == "auto-discovered"));
6724 }
6725
6726 #[test]
6727 fn explicit_user_row_is_not_pruned_by_auto_discovery_tag() {
6728 let _environment = crate::openrouter::test_environment_scope();
6729 let tmp = TempDir::new().unwrap();
6730 let models = tmp.path().join("models");
6731 let model_dir = models.join("Explicit-Custom-Llama");
6732 write_mlx_dir(&models, "Explicit-Custom-Llama", "llama");
6733
6734 let mut registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6735 let model_id = "local/explicit-custom-llama";
6736 let schema = registry.get(model_id).cloned().unwrap();
6737 assert!(schema.tags.iter().any(|tag| tag == "auto-discovered"));
6738
6739 registry.register_user_model(schema);
6740 std::fs::remove_dir_all(model_dir).unwrap();
6741 registry.prune_missing_on_disk_models();
6742
6743 assert!(
6744 registry.get(model_id).is_some(),
6745 "explicit registration provenance must outrank a user-controlled tag"
6746 );
6747 }
6748
6749 #[test]
6754 fn colliding_directory_does_not_claim_a_registered_row() {
6755 let tmp = TempDir::new().unwrap();
6756 let models = tmp.path().join("models");
6757 write_mlx_dir(&models, "Collide-User-Llama", "llama");
6758 write_mlx_dir(&models, "Collide-Project-Llama", "llama");
6759 let mut registry = UnifiedRegistry::new_empty(models.clone());
6760
6761 let mut user =
6762 synthesize_local_schema("Collide-User-Llama", &models.join("Collide-User-Llama"))
6763 .unwrap();
6764 user.name = "Configured-Elsewhere-User".into();
6765 let user_id = user.id.clone();
6766 registry.register_user_model(user);
6767
6768 let mut project = synthesize_local_schema(
6769 "Collide-Project-Llama",
6770 &models.join("Collide-Project-Llama"),
6771 )
6772 .unwrap();
6773 project.name = "Configured-Elsewhere-Project".into();
6774 let project_id = project.id.clone();
6775 assert!(registry.register_project_model(project));
6776
6777 registry.discover_on_disk_models();
6778 assert_eq!(
6779 registry.get(&user_id).map(|m| m.name.as_str()),
6780 Some("Configured-Elsewhere-User"),
6781 "discovery must not replace a registered row"
6782 );
6783
6784 std::fs::remove_dir_all(models.join("Collide-User-Llama")).unwrap();
6785 std::fs::remove_dir_all(models.join("Collide-Project-Llama")).unwrap();
6786 registry.prune_missing_on_disk_models();
6787
6788 assert!(
6789 registry.get(&user_id).is_some(),
6790 "removing a colliding directory must not erase a user row"
6791 );
6792 assert!(
6793 registry.get(&project_id).is_some(),
6794 "removing a colliding directory must not erase a project row"
6795 );
6796 }
6797
6798 #[test]
6799 fn signed_catalog_cannot_shadow_builtin_exact_id() {
6800 let _environment = crate::openrouter::test_environment_scope();
6805 let tmp = TempDir::new().unwrap();
6809 let models_dir = tmp.path().join("models");
6810
6811 let builtin = builtin_catalog();
6812 let mut overriding = builtin.first().expect("a built-in model").clone();
6813 let target_id = overriding.id.clone();
6814 overriding.name = "REPLACED-BY-CATALOG".into();
6815
6816 let (verified, public_key) = crate::catalog::signed_test_catalog(
6817 crate::catalog::CatalogDoc {
6818 revoked: Vec::new(),
6819 version: 1,
6820 models: vec![overriding],
6821 },
6822 51,
6823 );
6824 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
6825
6826 let reg = UnifiedRegistry::new_with_catalog_public_key(
6828 tmp.path().to_path_buf(),
6829 models_dir,
6830 Some(public_key.as_str()),
6831 );
6832 assert_eq!(
6833 reg.get(&target_id).map(|m| m.name.as_str()),
6834 Some(builtin.first().unwrap().name.as_str()),
6835 "a signed cache row must not replace a builtin exact id"
6836 );
6837 }
6838
6839 #[test]
6840 fn user_model_cannot_shadow_project_owned_exact_id() {
6841 let mut reg = test_registry();
6842 let original = builtin_catalog().first().expect("a builtin").clone();
6843 let mut forged = original.clone();
6844 forged.name = "USER-SHADOW".into();
6845
6846 reg.register_user_model(forged);
6847
6848 assert_eq!(
6849 reg.get(&original.id).map(|model| model.name.as_str()),
6850 Some(original.name.as_str()),
6851 "a user row must not replace a project-owned exact id"
6852 );
6853 }
6854
6855 #[test]
6856 fn legacy_unsigned_catalog_cache_cannot_replace_builtin() {
6857 let _environment = crate::openrouter::test_environment_scope();
6862 let tmp = TempDir::new().unwrap();
6863 let models_dir = tmp.path().join("models");
6864 let builtin = builtin_catalog();
6865 let original = builtin.first().expect("a built-in model");
6866 let mut forged = original.clone();
6867 forged.name = "FORGED-UNSIGNED-CATALOG".into();
6868 let path = crate::catalog::cache_path(tmp.path());
6869 std::fs::write(
6870 &path,
6871 serde_json::to_vec_pretty(&crate::catalog::CatalogDoc {
6872 revoked: Vec::new(),
6873 version: u64::MAX,
6874 models: vec![forged],
6875 })
6876 .unwrap(),
6877 )
6878 .unwrap();
6879
6880 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
6881 assert_eq!(
6882 reg.get(&original.id).map(|model| model.name.as_str()),
6883 Some(original.name.as_str()),
6884 "legacy unsigned cache JSON must fail closed and preserve the built-in"
6885 );
6886 }
6887
6888 #[test]
6889 fn tampered_signed_managed_row_preserves_builtin() {
6890 let _environment = crate::openrouter::test_environment_scope();
6895 let tmp = TempDir::new().unwrap();
6896 let models_dir = tmp.path().join("models");
6897 let original = builtin_catalog()
6898 .into_iter()
6899 .find(|model| model.id == "parslee/openrouter/frontier-general")
6900 .expect("managed frontier alias");
6901 let mut forged = original.clone();
6902 forged.name = "SIGNED-THEN-TAMPERED-MANAGED".into();
6903 let (verified, public_key) = crate::catalog::signed_test_catalog(
6904 crate::catalog::CatalogDoc {
6905 revoked: Vec::new(),
6906 version: 9,
6907 models: vec![forged],
6908 },
6909 52,
6910 );
6911 let path = crate::catalog::cache_path(tmp.path());
6912 crate::catalog::save_verified(&path, &verified).unwrap();
6913 let cache = std::fs::read_to_string(&path)
6914 .unwrap()
6915 .replace("SIGNED-THEN-TAMPERED-MANAGED", "ATTACKER-MUTATION");
6916 std::fs::write(&path, cache).unwrap();
6917
6918 let reg = UnifiedRegistry::new_with_catalog_public_key(
6919 tmp.path().to_path_buf(),
6920 models_dir,
6921 Some(public_key.as_str()),
6922 );
6923 assert_eq!(
6924 reg.get(&original.id).map(|model| model.name.as_str()),
6925 Some(original.name.as_str()),
6926 "a tampered same-id managed row must fail verification and preserve the builtin"
6927 );
6928 }
6929
6930 #[test]
6931 fn builtin_catalog_loads() {
6932 let reg = test_registry();
6933 let all = reg.list();
6934 assert_eq!(all.len(), builtin_catalog().len());
6935 }
6936
6937 #[test]
6938 fn subscription_billing_lookup_uses_exact_or_implicit_latest_ids() {
6939 let codex_ids: Vec<_> = builtin_catalog()
6940 .into_iter()
6941 .filter(|model| model.id.starts_with("openai-codex/"))
6942 .map(|model| model.id)
6943 .collect();
6944 assert!(!codex_ids.is_empty());
6945 for id in codex_ids {
6946 assert!(is_builtin_subscription_billed(&id), "{id}");
6947 if let Some(untagged) = id.strip_suffix(":latest") {
6948 assert!(is_builtin_subscription_billed(untagged), "{untagged}");
6949 }
6950 }
6951 assert!(!is_builtin_subscription_billed("parslee/reasoning"));
6952 assert!(!is_builtin_subscription_billed("openai/gpt-5.5-2026-04-23"));
6953 assert!(!is_builtin_subscription_billed("unknown/not-a-model"));
6954 }
6955
6956 #[test]
6957 fn shipped_supervised_vllm_models_use_the_managed_source_contract() {
6958 let managed = builtin_catalog()
6959 .into_iter()
6960 .filter(|model| model.id.starts_with("vllm-mlx/"))
6961 .collect::<Vec<_>>();
6962 assert_eq!(managed.len(), 15);
6963 assert!(managed.iter().all(ModelSchema::is_car_managed_vllm_mlx));
6964 assert!(managed.iter().all(ModelSchema::downloads_weights));
6965 }
6966
6967 #[test]
6980 fn mlx_vlm_models_reflect_runtime_availability() {
6981 let reg = test_registry();
6982 let mlx_vlm_models: Vec<&ModelSchema> = reg
6983 .list()
6984 .into_iter()
6985 .filter(|m| m.tags.iter().any(|t| t == "requires-mlx-vlm"))
6986 .collect();
6987 assert!(
6988 !mlx_vlm_models.is_empty(),
6989 "catalog should contain at least one model tagged \
6990 `requires-mlx-vlm` — otherwise this regression has \
6991 nothing to guard"
6992 );
6993
6994 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6995 let expected = crate::backend::mlx_vlm_cli::is_available();
6996 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6997 let expected = false;
6998
6999 for m in mlx_vlm_models {
7000 assert_eq!(
7001 m.available, expected,
7002 "model {} `available` field should reflect \
7003 mlx_vlm CLI presence (expected {expected}, got {})",
7004 m.id, m.available
7005 );
7006 }
7007 }
7008
7009 #[test]
7021 fn mlx_models_unavailable_on_non_mlx_targets() {
7022 let reg = test_registry();
7023 let mlx_models: Vec<&ModelSchema> = reg
7024 .list()
7025 .into_iter()
7026 .filter(|m| {
7027 m.is_mlx()
7028 && !m.tags.iter().any(|t| t == "requires-mlx-vlm")
7033 && !m.tags.contains(&"speech".to_string())
7034 })
7035 .collect();
7036 assert!(
7037 !mlx_models.is_empty(),
7038 "catalog should contain at least one plain MLX model — \
7039 otherwise this F1 regression guard has nothing to guard"
7040 );
7041
7042 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7043 {
7044 let any_available = mlx_models.iter().any(|m| m.available);
7048 assert!(
7049 any_available,
7050 "on macOS arm64 with MLX enabled, at least one plain MLX \
7051 model with hf_repo should be available — none were"
7052 );
7053 }
7054 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7055 {
7056 for m in &mlx_models {
7059 assert!(
7060 !m.available,
7061 "MLX model {} is marked available on a non-MLX target — \
7062 the adaptive router will add it to fallback chains \
7063 and dispatch will fail (Parslee-ai/car#231 §7.1)",
7064 m.id
7065 );
7066 }
7067 }
7068 }
7069
7070 fn is_builtin_model_id_form(id: &str) -> bool {
7075 fn part(s: &str) -> bool {
7076 let mut chars = s.chars();
7077 chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
7078 && chars.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
7079 }
7080 let (body, tag) = match id.split_once(':') {
7081 Some((body, tag)) => (body, Some(tag)),
7082 None => (id, None),
7083 };
7084 let Some((provider, name)) = body.split_once('/') else {
7085 return false;
7086 };
7087 part(provider) && part(name) && tag.is_none_or(part)
7088 }
7089
7090 #[test]
7091 fn builtin_model_ids_follow_the_resolved_model_id_contract() {
7092 for good in [
7093 "anthropic/claude-opus-4-8",
7094 "openai/gpt-5.5-2026-04-23",
7095 "qwen/qwen3-4b:q4_k_m",
7096 ] {
7097 assert!(is_builtin_model_id_form(good), "{good} should pass");
7098 }
7099 for bad in [
7100 "",
7101 "gpt-5.5",
7102 "/name",
7103 "openai/",
7104 "-openai/x",
7105 "openai/-x",
7106 "open ai/x",
7107 "openai/x y",
7108 "openai/é",
7109 "parslee/openrouter/open-fast",
7110 "openai/x:",
7111 "openai/x:-a",
7112 "openai/x:a b",
7113 "openai/x:a:b",
7114 "openai/x:a/b",
7115 ] {
7116 assert!(!is_builtin_model_id_form(bad), "{bad:?} should fail");
7117 }
7118 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7119 let off: Vec<&str> = catalog
7120 .iter()
7121 .map(|m| m.id.as_str())
7122 .filter(|id| !is_builtin_model_id_form(id))
7123 .collect();
7124 assert!(off.is_empty(), "built-in ids outside the contract: {off:?}");
7125 }
7126
7127 #[test]
7128 fn builtin_catalog_json_parses() {
7129 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON)
7130 .expect("builtin_catalog.json must be valid ModelSchema array");
7131 assert!(
7132 !catalog.is_empty(),
7133 "embedded catalog has no entries — that's almost certainly wrong"
7134 );
7135
7136 let mut seen = std::collections::HashSet::new();
7137 for entry in &catalog {
7138 assert!(
7139 seen.insert(entry.id.clone()),
7140 "duplicate id in builtin_catalog.json: {}",
7141 entry.id
7142 );
7143 }
7144 }
7145
7146 #[test]
7147 fn codex_subscription_row_has_pinnable_tool_proposal_identity() {
7148 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7149 let row = catalog
7150 .iter()
7151 .find(|model| model.id == "openai/gpt-5.6-sol:high")
7152 .expect("catalog publishes the Codex subscription identity");
7153 assert_eq!(row.name, "gpt-5.6-sol:high");
7154 assert!(matches!(
7155 &row.source,
7156 ModelSource::CodexCli { model } if model == "gpt-5.6-sol:high"
7157 ));
7158 assert!(row.has_capability(ModelCapability::Generate));
7159 assert!(row.has_capability(ModelCapability::Reasoning));
7160 assert!(row.has_capability(ModelCapability::ToolUse));
7161 assert!(row.has_capability(ModelCapability::MultiToolCall));
7162 assert!(row.has_capability(ModelCapability::Vision));
7164 assert!(!row.tags.iter().any(|tag| tag == "text-only"));
7165 assert_eq!(row.supported_params, vec![GenerateParam::MaxTokens]);
7166 assert!(!row.downloads_weights());
7167 }
7168
7169 #[test]
7170 fn codex_subscription_rows_publish_sol_6_1_without_invented_measurements() {
7171 let catalog: serde_json::Value = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7172 let rows = catalog.as_array().unwrap();
7173 let old = rows
7174 .iter()
7175 .find(|r| r["id"] == "openai-codex/gpt-5.6-sol:high")
7176 .unwrap();
7177 for id in [
7178 "openai-codex/gpt-6.1-sol:high",
7179 "openai-codex/gpt-6.1-sol:medium",
7180 ] {
7181 let row = rows.iter().find(|r| r["id"] == id).unwrap();
7182 assert_eq!(row["family"], "gpt-6.1");
7183 assert_eq!(row["context_length"], 500000);
7184 assert_eq!(row["capabilities"], old["capabilities"]);
7185 assert_eq!(row["tags"], old["tags"]);
7186 assert_eq!(row["source"], old["source"]);
7187 assert_eq!(row["source"]["auth"]["type"], "chatgpt_subscription");
7188 assert_eq!(row["source"]["protocol"]["chat_path"], "/codex/responses");
7189 assert!(row.get("public_benchmarks").is_none());
7190 }
7191 }
7192
7193 #[test]
7194 fn codex_subscription_rows_publish_the_gpt_5_6_family() {
7195 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7196 assert_eq!(
7197 catalog
7198 .iter()
7199 .filter(|model| model.provider == "openai-codex")
7200 .count(),
7201 8,
7202 "the explicit checks below must cover every openai-codex row"
7203 );
7204 for id in [
7205 "openai-codex/gpt-6.1-sol:high",
7206 "openai-codex/gpt-6.1-sol:medium",
7207 "openai-codex/gpt-5.6-sol:latest",
7208 "openai-codex/gpt-5.6-sol:high",
7209 "openai-codex/gpt-5.6-luna:latest",
7210 "openai-codex/gpt-5.6-luna:high",
7211 "openai-codex/gpt-5.6-terra:latest",
7212 "openai-codex/gpt-5.6-terra:high",
7213 ] {
7214 let row = catalog
7215 .iter()
7216 .find(|model| model.id == id)
7217 .unwrap_or_else(|| panic!("missing {id}"));
7218 assert_eq!(row.provider, "openai-codex", "{id}");
7219 assert!(
7220 matches!(&row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::ChatGptSubscription {}, .. } if provider == "openai-codex"),
7221 "{id}"
7222 );
7223 assert!(row.is_subscription_billed(), "{id}");
7224 assert!(
7225 row.cost.input_per_mtok.is_none() && row.cost.output_per_mtok.is_none(),
7226 "{id} must carry no dollar price"
7227 );
7228 assert!(row.tags.iter().any(|tag| tag == "subscription"), "{id}");
7229 assert!(
7230 row.cost
7231 .estimated_usd_bounded(Some(1_000), 1_000, 1_000, 0, 0)
7232 .is_none(),
7233 "{id} must not produce an approximate dollar cost"
7234 );
7235 let mut accidentally_priced = row.clone();
7236 accidentally_priced.cost.input_per_mtok = Some(1.0);
7237 accidentally_priced.cost.output_per_mtok = Some(1.0);
7238 assert!(
7239 crate::scoreboard_price_model(&accidentally_priced).is_none(),
7240 "{id} must stay out of the dollar scoreboard"
7241 );
7242 assert!(row.has_capability(ModelCapability::ToolUse), "{id}");
7243 assert!(row.has_capability(ModelCapability::Vision), "{id}");
7245 assert_eq!(row.name.ends_with(":high"), id.ends_with(":high"), "{id}");
7246 }
7247 let cli = catalog
7248 .iter()
7249 .find(|model| model.id == "openai/gpt-5.6-sol:high")
7250 .unwrap();
7251 assert!(
7252 matches!(&cli.source, ModelSource::CodexCli { .. }),
7253 "the Codex CLI row stays as it was"
7254 );
7255 assert!(!cli.is_subscription_billed());
7256 let api = catalog
7257 .iter()
7258 .find(|model| model.id == "openai/gpt-5.6-sol:latest")
7259 .unwrap();
7260 assert!(
7261 matches!(&api.source, ModelSource::RemoteApi { .. }),
7262 "the API-key row stays as it was"
7263 );
7264 assert!(!api.is_subscription_billed());
7265 let parslee = catalog
7266 .iter()
7267 .find(|model| model.id == "parslee/fast")
7268 .expect("catalog publishes a Parslee row");
7269 assert!(!parslee.is_subscription_billed());
7270 }
7271
7272 #[test]
7278 fn a_codex_lookup_error_clears_the_shared_hint_and_is_recorded_as_unknown() {
7279 let _environment = crate::openrouter::test_environment_scope();
7280 let tmp = TempDir::new().unwrap();
7281 let mut registry = UnifiedRegistry::new_with_state_root(
7282 tmp.path().to_path_buf(),
7283 tmp.path().join("models"),
7284 );
7285 let codex_id = "openai-codex/gpt-5.6-sol:latest";
7286
7287 remember_codex_sign_in(true);
7288 registry.record_codex_lookup(Err("the secret store is locked".into()));
7289
7290 assert!(
7291 !passive_codex_oauth_available(),
7292 "a lookup error clears the shared hint, as on main"
7293 );
7294 registry.refresh_availability();
7295 let row = registry.get(codex_id).expect("builtin Codex row").clone();
7296 assert!(
7297 !row.available,
7298 "callers reading only `available` see it unavailable"
7299 );
7300 registry.record_codex_lookup(Err("the secret store is locked".into()));
7301 assert!(
7302 registry.row_credential_state_unknown(&row),
7303 "the planner's unknown set holds the row"
7304 );
7305
7306 registry.record_codex_lookup(Ok(true));
7307 assert!(passive_codex_oauth_available());
7308 remember_codex_sign_in(false);
7309 }
7310
7311 #[test]
7312 fn codex_rows_unavailable_without_sign_in_carry_the_sign_in_reason() {
7313 let _environment = crate::openrouter::test_environment_scope();
7314 remember_codex_sign_in(false);
7315
7316 let tmp = TempDir::new().unwrap();
7317 let mut registry = UnifiedRegistry::new_with_state_root(
7318 tmp.path().to_path_buf(),
7319 tmp.path().join("models"),
7320 );
7321 let expected_ids = [
7322 "openai-codex/gpt-6.1-sol:high",
7323 "openai-codex/gpt-6.1-sol:medium",
7324 "openai-codex/gpt-5.6-sol:latest",
7325 "openai-codex/gpt-5.6-sol:high",
7326 "openai-codex/gpt-5.6-luna:latest",
7327 "openai-codex/gpt-5.6-luna:high",
7328 "openai-codex/gpt-5.6-terra:latest",
7329 "openai-codex/gpt-5.6-terra:high",
7330 ];
7331
7332 registry
7334 .models
7335 .get_mut("openai/gpt-6.1-sol:latest")
7336 .unwrap()
7337 .available = true;
7338 for id in expected_ids {
7339 let info = ModelInfo::from(
7340 registry
7341 .get(id)
7342 .unwrap_or_else(|| panic!("missing builtin Codex row {id}")),
7343 );
7344 assert!(!info.available, "{id} must be unavailable while signed out");
7345 let rows: Vec<_> = registry.all().cloned().collect();
7346 let resolved = find_model_row(&rows, id).expect("explicit subscription resolution");
7347 assert_eq!(
7348 resolved.id, id,
7349 "signed-out subscription must not fall back to an API row"
7350 );
7351 assert!(!resolved.available);
7352 let reason = model_unavailable_reason(&info).expect("signed-out Codex reason");
7353 assert_eq!(reason, crate::schema::OPENAI_CODEX_SIGN_IN_HINT);
7354 assert_eq!(
7355 serde_json::to_value(&info).unwrap()["unavailable_reason"],
7356 reason
7357 );
7358 assert!(reason.contains("car auth login --provider openai-codex"));
7359 assert!(reason.contains("Settings → AI accounts"));
7360 }
7361
7362 remember_codex_sign_in(true);
7363 registry.refresh_availability();
7364
7365 for id in expected_ids {
7366 let info = ModelInfo::from(registry.get(id).unwrap());
7367 assert!(info.available, "{id} must be available while signed in");
7368 assert_eq!(model_unavailable_reason(&info), None, "{id}");
7369 assert!(serde_json::to_value(&info).unwrap()["unavailable_reason"].is_null());
7370 }
7371
7372 remember_codex_sign_in(false);
7373 }
7374
7375 #[tokio::test]
7376 async fn signed_out_subscription_generation_never_dispatches_api_fallback() {
7377 let _slot = crate::runner::test_slot_lock().lock().await;
7378 let _environment = crate::openrouter::test_environment_scope_async().await;
7379 remember_codex_sign_in(false);
7380 let root = TempDir::new().unwrap();
7381 struct Restore;
7382 impl Drop for Restore {
7383 fn drop(&mut self) {
7384 crate::set_inference_runner(None);
7385 }
7386 }
7387 struct ApiProbe(std::sync::Arc<std::sync::atomic::AtomicUsize>);
7388 #[async_trait::async_trait]
7389 impl crate::InferenceRunner for ApiProbe {
7390 async fn run(
7391 &self,
7392 _: crate::GenerateRequest,
7393 _: crate::EventEmitter,
7394 ) -> Result<crate::RunnerResult, crate::RunnerError> {
7395 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7396 Ok(crate::RunnerResult {
7397 text: "forbidden fallback".into(),
7398 tool_calls: vec![],
7399 })
7400 }
7401 }
7402 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
7403 let _restore = Restore;
7404 crate::set_inference_runner(Some(std::sync::Arc::new(ApiProbe(calls.clone()))));
7405 let mut engine = crate::InferenceEngine::new(crate::InferenceConfig {
7406 state_root: root.path().join("state"),
7407 models_dir: root.path().join("models"),
7408 ..Default::default()
7409 });
7410 let mut alternative = engine
7411 .registered_schema("openai/gpt-6.1-sol:latest")
7412 .unwrap();
7413 alternative.source = ModelSource::Delegated {
7414 hint: Some("instrumented-api-alternative".into()),
7415 };
7416 alternative.available = true;
7417 engine.register_model(alternative);
7418 for id in [
7419 "openai-codex/gpt-6.1-sol:high",
7420 "openai-codex/gpt-6.1-sol:medium",
7421 ] {
7422 assert!(!engine.registered_schema(id).unwrap().available);
7423 let mut request = crate::GenerateRequest {
7424 prompt: "signed-out probe".into(),
7425 model: Some(id.into()),
7426 ..Default::default()
7427 };
7428 request.params.strict_model = true;
7429 let result = engine.generate_tracked(request).await;
7430 assert!(result.is_err(), "signed-out request unexpectedly succeeded");
7431 assert_eq!(
7432 calls.load(std::sync::atomic::Ordering::SeqCst),
7433 0,
7434 "API fallback executed"
7435 );
7436 }
7437 }
7438
7439 #[test]
7440 fn work_context_availability_is_independent_of_other_org_rejection() {
7441 let _environment = crate::openrouter::test_environment_scope();
7442 crate::parslee_credential::clear_credential_rejected();
7443 let tmp = TempDir::new().unwrap();
7444 let mut parslee = UnifiedRegistry::new_with_state_root(
7445 tmp.path().to_path_buf(),
7446 tmp.path().join("models"),
7447 );
7448 let mut flyexclusive = parslee.clone();
7449 crate::parslee_credential::note_credential_rejected();
7450 parslee.refresh_work_context_availability("https://authority.example", true);
7451 flyexclusive.refresh_work_context_availability("https://authority.example", false);
7452 let scoped_rows: Vec<_> = parslee.all().filter(|row| matches!(
7453 &row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::OAuth2Pkce { .. }, .. }
7454 if provider == "parslee"
7455 )).collect();
7456 assert!(!scoped_rows.is_empty());
7457 for row in scoped_rows {
7458 assert!(row.available);
7459 assert!(!flyexclusive.get(&row.id).unwrap().available);
7460 }
7461 assert!(
7462 crate::parslee_credential::credential_rejected(),
7463 "scoped routing must not clear another context's observation"
7464 );
7465 crate::parslee_credential::clear_credential_rejected();
7466 }
7467
7468 #[test]
7469 fn codex_availability_leaves_openai_and_parslee_rows_alone() {
7470 let _environment = crate::openrouter::test_environment_scope();
7471 remember_codex_sign_in(false);
7472
7473 let tmp = TempDir::new().unwrap();
7474 let mut registry = UnifiedRegistry::new_with_state_root(
7475 tmp.path().to_path_buf(),
7476 tmp.path().join("models"),
7477 );
7478 registry.refresh_availability();
7479
7480 let unaffected_ids: Vec<String> = registry
7481 .list()
7482 .into_iter()
7483 .filter(|row| {
7484 matches!(
7485 row.id.as_str(),
7486 "openai/gpt-5.6-sol:latest" | "openai/gpt-5.6-sol:high"
7487 ) || row.id.starts_with("parslee/")
7488 })
7489 .map(|row| row.id.clone())
7490 .collect();
7491 assert!(unaffected_ids
7492 .iter()
7493 .any(|id| id == "openai/gpt-5.6-sol:latest"));
7494 assert!(unaffected_ids
7495 .iter()
7496 .any(|id| id == "openai/gpt-5.6-sol:high"));
7497 assert!(
7498 unaffected_ids.iter().any(|id| id.starts_with("parslee/")),
7499 "builtin catalog must contain Parslee rows"
7500 );
7501 let signed_out: std::collections::HashMap<String, bool> = unaffected_ids
7502 .iter()
7503 .map(|id| (id.clone(), registry.get(id).unwrap().available))
7504 .collect();
7505
7506 remember_codex_sign_in(true);
7507 registry.refresh_availability();
7508
7509 for id in unaffected_ids {
7510 assert_eq!(
7511 registry.get(&id).unwrap().available,
7512 signed_out[&id],
7513 "Codex sign-in must not change {id} availability"
7514 );
7515 }
7516
7517 remember_codex_sign_in(false);
7518 }
7519
7520 #[test]
7521 fn builtin_catalog_names_are_unique_for_codex_rows() {
7522 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7523 for row in catalog.iter().filter(|row| row.provider == "openai-codex") {
7524 let occurrences = catalog
7525 .iter()
7526 .filter(|candidate| candidate.name == row.name)
7527 .count();
7528 assert_eq!(
7529 occurrences, 1,
7530 "openai-codex name must be unique: {}",
7531 row.name
7532 );
7533 }
7534 }
7535
7536 #[test]
7559 fn apple_foundation_row_claims_single_tool_call_and_the_real_context_window() {
7560 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7561 let row = catalog
7562 .iter()
7563 .find(|model| model.id == "apple/foundation:default")
7564 .expect("catalog publishes the Apple FoundationModels row");
7565 assert!(matches!(
7566 &row.source,
7567 ModelSource::AppleFoundationModels { .. }
7568 ));
7569 assert!(row.has_capability(ModelCapability::Generate));
7570 assert!(
7571 row.has_capability(ModelCapability::ToolUse),
7572 "generate_with_tools exists, so the router must be able to pick this row for tool routes"
7573 );
7574 assert!(
7575 !row.has_capability(ModelCapability::MultiToolCall),
7576 "the capture sentinel ends the turn on the first tool call"
7577 );
7578 assert!(!row.has_capability(ModelCapability::Vision));
7579 assert_eq!(
7580 row.context_length, 4096,
7581 "SystemLanguageModel's context window is 4096 tokens"
7582 );
7583 assert!(!row.downloads_weights());
7584 }
7585
7586 #[test]
7599 fn apple_foundation_multi_tool_call_tracks_what_the_host_can_actually_do() {
7600 #[cfg(any(
7601 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7602 all(target_os = "ios", target_arch = "aarch64")
7603 ))]
7604 {
7605 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7606 let declared = catalog
7607 .iter()
7608 .find(|m| m.id == "apple/foundation:default")
7609 .expect("catalog publishes the Apple FoundationModels row");
7610 assert!(
7611 !declared.has_capability(ModelCapability::MultiToolCall),
7612 "the catalog literal must stay conservative; the host decides"
7613 );
7614
7615 let models_dir = tempfile::tempdir().unwrap();
7617 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
7618 let live = registry
7624 .models
7625 .get("apple/foundation:default")
7626 .expect("the builtin catalog row must survive into the live registry");
7627 let host_can = crate::backend::foundation_models::supports_parallel_tool_calls();
7628 assert_eq!(
7629 live.capabilities.contains(&ModelCapability::MultiToolCall),
7630 host_can,
7631 "live row must claim multi_tool_call iff the host supports it"
7632 );
7633 }
7634 }
7635
7636 #[test]
7648 fn apple_foundation_claims_vision_only_where_images_can_be_served() {
7649 #[cfg(any(
7650 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7651 all(target_os = "ios", target_arch = "aarch64")
7652 ))]
7653 {
7654 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7655 let declared = catalog
7656 .iter()
7657 .find(|m| m.id == "apple/foundation:default")
7658 .expect("catalog publishes the Apple FoundationModels row");
7659 assert!(
7660 !declared.has_capability(ModelCapability::Vision),
7661 "the catalog literal must stay conservative; the device decides"
7662 );
7663
7664 let models_dir = tempfile::tempdir().unwrap();
7665 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
7666 let live = registry
7667 .models
7668 .get("apple/foundation:default")
7669 .expect("the builtin catalog row must survive into the live registry");
7670
7671 assert_eq!(
7672 live.capabilities.contains(&ModelCapability::Vision),
7673 crate::backend::foundation_models::supports_vision(),
7674 "the live row must claim vision iff this device's model accepts images"
7675 );
7676 }
7677 }
7678
7679 #[test]
7696 fn a_host_without_parallel_support_gets_exactly_the_catalog_capabilities() {
7697 #[cfg(any(
7698 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7699 all(target_os = "ios", target_arch = "aarch64")
7700 ))]
7701 {
7702 if crate::backend::foundation_models::supports_parallel_tool_calls() {
7703 return;
7704 }
7705 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7706 let declared = catalog
7707 .iter()
7708 .find(|m| m.id == "apple/foundation:default")
7709 .expect("catalog publishes the Apple FoundationModels row");
7710
7711 let models_dir = tempfile::tempdir().unwrap();
7712 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
7713 let live = registry
7714 .models
7715 .get("apple/foundation:default")
7716 .expect("the builtin catalog row must survive into the live registry");
7717
7718 assert_eq!(
7719 live.capabilities, declared.capabilities,
7720 "a host without the macOS 27 behaviour must advertise exactly what \
7721 the catalog declares — nothing added, nothing removed"
7722 );
7723 }
7724 }
7725
7726 #[test]
7736 fn apple_foundation_context_size_is_either_the_os_answer_or_none() {
7737 #[cfg(any(
7738 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7739 all(target_os = "ios", target_arch = "aarch64")
7740 ))]
7741 {
7742 if let Some(window) = crate::backend::foundation_models::context_size() {
7743 assert!(
7744 window >= 512,
7745 "a context window the framework reports must be usable, got {window}"
7746 );
7747 }
7748 }
7749
7750 #[cfg(not(any(
7753 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7754 all(target_os = "ios", target_arch = "aarch64")
7755 )))]
7756 {
7757 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7758 let row = catalog
7759 .iter()
7760 .find(|model| model.id == "apple/foundation:default")
7761 .expect("catalog publishes the Apple FoundationModels row");
7762 assert_eq!(row.context_length, 4096);
7763 }
7764 }
7765
7766 #[test]
7773 fn in_process_qwen3_models_declare_tool_use() {
7774 use crate::schema::ModelSource;
7775 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7776 let tool_sizes = ["qwen3-4b", "qwen3-8b", "qwen3-30b-a3b"];
7779 let mut checked = 0;
7780 for entry in &catalog {
7781 let in_process = matches!(
7782 entry.source,
7783 ModelSource::Mlx { .. } | ModelSource::Local { .. }
7784 );
7785 if !in_process || !tool_sizes.iter().any(|s| entry.id.contains(s)) {
7786 continue;
7787 }
7788 assert!(
7789 entry.capabilities.contains(&ModelCapability::ToolUse),
7790 "in-process Qwen3 model {} should advertise ToolUse — the local \
7791 generate path renders/parses tool calls",
7792 entry.id
7793 );
7794 checked += 1;
7795 }
7796 assert_eq!(
7797 checked, 6,
7798 "expected 6 in-process tool-capable Qwen3 entries (3 mlx + 3 gguf)"
7799 );
7800 }
7801
7802 #[test]
7803 fn public_benchmarks_round_trip_through_model_info() {
7804 use crate::schema::BenchmarkScore;
7805 let mut reg = test_registry();
7806 let mut schema = reg
7807 .find_by_name("Qwen3-4B")
7808 .expect("catalog has Qwen3-4B")
7809 .clone();
7810 schema.id = "test/qwen3-4b-with-bench".into();
7811 schema.public_benchmarks = vec![
7812 BenchmarkScore {
7813 name: "MMLU-Pro".into(),
7814 score: 0.482,
7815 harness: Some("5-shot CoT".into()),
7816 source_url: Some("https://example.invalid/qwen3-4b-card".into()),
7817 measured_at: Some("2025-08-12".into()),
7818 runs: None,
7819 spread: None,
7820 },
7821 BenchmarkScore {
7822 name: "HumanEval".into(),
7823 score: 0.713,
7824 harness: Some("pass@1".into()),
7825 source_url: None,
7826 measured_at: None,
7827 runs: None,
7828 spread: None,
7829 },
7830 ];
7831 reg.register(schema);
7832
7833 let stored = reg
7834 .get("test/qwen3-4b-with-bench")
7835 .expect("registered model is retrievable");
7836 let info = ModelInfo::from(stored);
7837 assert_eq!(info.public_benchmarks.len(), 2);
7838
7839 let json = serde_json::to_string(&info).unwrap();
7841 assert!(json.contains("\"public_benchmarks\""));
7842 assert!(json.contains("\"MMLU-Pro\""));
7843 assert!(json.contains("\"5-shot CoT\""));
7844
7845 let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
7847 assert_eq!(decoded.public_benchmarks.len(), 2);
7848 assert_eq!(decoded.public_benchmarks[0].name, "MMLU-Pro");
7849 assert_eq!(decoded.public_benchmarks[1].name, "HumanEval");
7850 }
7851
7852 #[test]
7853 fn public_benchmarks_default_to_empty_when_absent_in_json() {
7854 let legacy_json = r#"{
7857 "id": "legacy/test:1",
7858 "name": "Legacy Test",
7859 "provider": "test",
7860 "family": "test",
7861 "version": "",
7862 "capabilities": ["generate"],
7863 "context_length": 4096,
7864 "param_count": "1B",
7865 "quantization": null,
7866 "performance": {},
7867 "cost": {},
7868 "source": { "type": "ollama", "model_tag": "legacy:1" },
7869 "tags": [],
7870 "supported_params": []
7871 }"#;
7872 let schema: ModelSchema = serde_json::from_str(legacy_json).unwrap();
7873 assert!(schema.public_benchmarks.is_empty());
7874 }
7875
7876 #[test]
7877 fn find_by_name() {
7878 let reg = test_registry();
7879 let m = reg.find_by_name("Qwen3-4B").unwrap();
7880 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7881 assert_eq!(m.id, "mlx/qwen3-4b:4bit");
7882 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7883 assert_eq!(m.id, "qwen/qwen3-4b:q4_k_m");
7884 assert!(m.has_capability(ModelCapability::Code));
7885 }
7886
7887 #[test]
7891 fn an_untagged_id_names_its_latest_row() {
7892 let reg = test_registry();
7893 let resolved = |model: &str| {
7894 reg.get(model)
7895 .or_else(|| reg.find_by_name(model))
7896 .map(|m| m.id.clone())
7897 };
7898 assert_eq!(
7899 resolved("anthropic/claude-haiku-4-5").as_deref(),
7900 Some("anthropic/claude-haiku-4-5:latest")
7901 );
7902 assert_eq!(
7905 resolved("anthropic/claude-opus-4-8").as_deref(),
7906 Some("anthropic/claude-opus-4-8")
7907 );
7908 assert_eq!(resolved("anthropic/claude-haiku-4-5:high"), None);
7909 assert_eq!(resolved("anthropic/claude-haiku-9"), None);
7910 assert_eq!(
7913 reg.find_by_name("anthropic/claude-opus-4-8")
7914 .map(|m| m.id.as_str()),
7915 None
7916 );
7917
7918 let rows = builtin_catalog();
7920 let row = |model: &str| find_model_row(&rows, model).map(|m| m.id.clone());
7921 assert_eq!(
7922 row("anthropic/claude-haiku-4-5").as_deref(),
7923 Some("anthropic/claude-haiku-4-5:latest")
7924 );
7925 assert_eq!(
7926 row("anthropic/claude-opus-4-8").as_deref(),
7927 Some("anthropic/claude-opus-4-8")
7928 );
7929 assert_eq!(
7930 row("claude-haiku-4-5").as_deref(),
7931 Some("anthropic/claude-haiku-4-5:latest")
7932 );
7933 assert_eq!(row("anthropic/claude-haiku-4-5:high"), None);
7934 assert_eq!(row("anthropic/claude-haiku-9"), None);
7935 }
7936
7937 #[test]
7938 fn query_by_capability() {
7939 let reg = test_registry();
7940 let embed_models = reg.query_by_capability(ModelCapability::Embed);
7941 assert_eq!(embed_models.len(), 2);
7942 assert!(embed_models
7943 .iter()
7944 .any(|model| model.name == "Qwen3-Embedding-0.6B"));
7945 assert!(embed_models
7946 .iter()
7947 .any(|model| model.name == "Qwen3-Embedding-0.6B-MLX"));
7948 }
7949
7950 #[test]
7951 fn query_with_filter() {
7952 let reg = test_registry();
7953 let code_small = reg.query(&ModelFilter {
7954 capabilities: vec![ModelCapability::Code],
7955 max_size_mb: Some(3000),
7956 local_only: true,
7957 ..Default::default()
7958 });
7959 assert_eq!(code_small.len(), 7);
7962 }
7963
7964 #[test]
7965 fn register_remote() {
7966 let mut reg = test_registry();
7967 let initial_len = reg.list().len();
7968 let initial_reasoning_len = reg
7969 .query(&ModelFilter {
7970 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
7971 ..Default::default()
7972 })
7973 .len();
7974 let remote = ModelSchema {
7975 id: "anthropic/claude-sonnet-4-6:latest".into(),
7976 name: "Claude Sonnet 4.6".into(),
7977 provider: "anthropic".into(),
7978 family: "claude-4".into(),
7979 version: "latest".into(),
7980 capabilities: vec![
7981 ModelCapability::Generate,
7982 ModelCapability::Code,
7983 ModelCapability::Reasoning,
7984 ModelCapability::ToolUse,
7985 ],
7986 context_length: 200000,
7987 max_output_tokens: None,
7988 param_count: String::new(),
7989 quantization: None,
7990 performance: PerformanceEnvelope {
7991 latency_p50_ms: Some(2000),
7992 ..Default::default()
7993 },
7994 cost: CostModel {
7995 input_per_mtok: Some(3.0),
7996 output_per_mtok: Some(15.0),
7997 ..Default::default()
7998 },
7999 source: ModelSource::RemoteApi {
8000 endpoint: "https://api.anthropic.com/v1/messages".into(),
8001 api_key_env: "ANTHROPIC_API_KEY".into(),
8002 api_key_envs: vec![],
8003 api_version: Some("2023-06-01".into()),
8004 protocol: ApiProtocol::Anthropic,
8005 },
8006 tags: vec![],
8007 supported_params: vec![],
8008 public_benchmarks: vec![],
8009 trust_tier: crate::schema::TrustTier::Curated,
8010 deprecated: false,
8011 available: false,
8012 weights_ready: false,
8013 };
8014
8015 reg.register(remote);
8016 assert_eq!(reg.list().len(), initial_len);
8018
8019 let reasoning = reg.query(&ModelFilter {
8020 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
8021 ..Default::default()
8022 });
8023 assert_eq!(reasoning.len(), initial_reasoning_len);
8025 }
8026
8027 #[test]
8028 fn unregister() {
8029 let mut reg = test_registry();
8030 let initial_len = reg.list().len();
8031 let removed = reg.unregister("qwen/qwen3-0.6b:q8_0");
8032 assert!(removed.is_some());
8033 assert_eq!(reg.list().len(), initial_len - 1);
8034 }
8035
8036 #[test]
8037 fn speech_models_are_curated() {
8038 let reg = test_registry();
8039 let stt = reg.query_by_capability(ModelCapability::SpeechToText);
8040 let tts = reg.query_by_capability(ModelCapability::TextToSpeech);
8041 assert_eq!(stt.len(), 3);
8043 assert_eq!(tts.len(), 5);
8045 let whisper = stt
8048 .iter()
8049 .find(|m| m.name == "Whisper-large-v3-turbo-q5_0")
8050 .expect("whisper STT model should be curated");
8051 assert!(whisper.is_local());
8052 assert!(matches!(
8053 whisper.source,
8054 crate::schema::ModelSource::WhisperCpp { .. }
8055 ));
8056 }
8057
8058 #[test]
8059 fn qwen_8b_variants_keep_tool_use_consistent() {
8060 let reg = test_registry();
8065 for name in ["Qwen3-8B", "Qwen3-8B-MLX"] {
8066 let model = reg.find_by_name(name).expect("model should exist");
8067 assert!(model.has_capability(ModelCapability::ToolUse));
8068 assert!(model.has_capability(ModelCapability::MultiToolCall));
8069 }
8070 }
8071
8072 #[test]
8073 fn mac_name_resolution_prefers_mlx_siblings() {
8074 #[allow(unused_variables)]
8077 let reg = test_registry();
8078 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
8079 {
8080 assert_eq!(
8081 reg.find_by_name("Qwen3-0.6B").unwrap().id,
8082 "mlx/qwen3-0.6b:6bit"
8083 );
8084 assert_eq!(
8085 reg.find_by_name("Qwen3-1.7B").unwrap().id,
8086 "mlx/qwen3-1.7b:3bit"
8087 );
8088 assert_eq!(
8089 reg.find_by_name("Qwen3-Embedding-0.6B").unwrap().id,
8090 "mlx/qwen3-embedding-0.6b:mxfp8"
8091 );
8092 }
8093 }
8094
8095 #[test]
8096 fn remote_multimodal_models_are_curated_as_vision_capable() {
8097 let reg = test_registry();
8098 for name in [
8099 "claude-opus-4-7",
8100 "claude-opus-4-6",
8101 "claude-sonnet-4-6",
8102 "claude-haiku-4-5",
8103 "gpt-5.4",
8104 "gpt-5.4-mini",
8105 "o3",
8106 "o4-mini",
8107 "gpt-4.1-mini",
8108 "gemini-2.5-pro",
8109 "gemini-2.5-flash",
8110 ] {
8111 let model = reg.find_by_name(name).expect("model should exist");
8112 assert!(
8113 model.has_capability(ModelCapability::Vision),
8114 "{name} should be curated as vision-capable"
8115 );
8116 }
8117 }
8118
8119 #[test]
8120 fn qwen25vl_entries_are_replaced_by_qwen3vl_in_builtin_catalog() {
8121 let reg = test_registry();
8122
8123 let stale_ids = [
8124 "mlx/qwen2.5-vl-3b:4bit",
8126 "mlx/qwen2.5-vl-7b:4bit",
8127 "mlx-vlm/qwen2.5-vl-3b:4bit",
8130 "mlx-vlm/qwen2.5-vl-7b:4bit",
8131 "vllm-mlx/qwen2.5-vl-3b:4bit",
8133 ];
8134 for id in stale_ids {
8135 assert!(
8136 reg.get(id).is_none(),
8137 "{id} is superseded by Qwen3-VL; the catalog must not advertise it"
8138 );
8139 }
8140
8141 let vision_ids: Vec<&str> = reg
8142 .query_by_capability(ModelCapability::Vision)
8143 .into_iter()
8144 .map(|model| model.id.as_str())
8145 .collect();
8146 for stale in stale_ids {
8147 assert!(
8148 !vision_ids.contains(&stale),
8149 "{stale} must not be reachable through the Vision capability index"
8150 );
8151 }
8152 assert!(
8153 vision_ids.contains(&"mlx-vlm/qwen3-vl-2b:bf16"),
8154 "Qwen3-VL is the supported local VL family and must route as Vision"
8155 );
8156 }
8157
8158 #[test]
8159 fn gemini_models_are_curated_for_multimodal_tool_use() {
8160 let reg = test_registry();
8161 for name in ["gemini-2.5-pro", "gemini-2.5-flash"] {
8162 let model = reg.find_by_name(name).expect("model should exist");
8163 assert!(model.has_capability(ModelCapability::Vision));
8164 assert!(model.has_capability(ModelCapability::ToolUse));
8165 assert!(model.has_capability(ModelCapability::MultiToolCall));
8166 }
8167 }
8168
8169 #[test]
8170 fn model_info_publishes_declared_prices_and_keeps_unpriced_distinct_from_free() {
8171 let reg = test_registry();
8172
8173 let opus = reg
8175 .list()
8176 .into_iter()
8177 .find(|m| m.id == "openrouter/anthropic/claude-opus-4.8")
8178 .map(ModelInfo::from)
8179 .expect("curated opus-4.8 row is present on first boot");
8180 assert_eq!(opus.cost.input_per_mtok, Some(5.0));
8181 assert_eq!(opus.cost.output_per_mtok, Some(25.0));
8182 assert_eq!(opus.cost.cache_read_input_per_mtok, Some(0.5));
8183 assert_eq!(opus.cost.cache_write_input_per_mtok, Some(6.25));
8184
8185 let gpt = reg
8187 .list()
8188 .into_iter()
8189 .find(|m| m.id == "openrouter/openai/gpt-5.4")
8190 .map(ModelInfo::from)
8191 .expect("curated gpt-5.4 row");
8192 assert_eq!(gpt.cost.pricing_tiers.len(), 1);
8193 assert_eq!(gpt.cost.prices_for(272_000).input_per_mtok, Some(5.0));
8194
8195 let local = reg
8198 .list()
8199 .into_iter()
8200 .find(|m| m.is_local() && m.cost.input_per_mtok.is_none())
8201 .map(ModelInfo::from)
8202 .expect("the built-in catalog ships unpriced local models");
8203 let json = serde_json::to_value(&local).unwrap();
8204 assert!(json["cost"]["input_per_mtok"].is_null());
8205 assert!(json["cost"]["output_per_mtok"].is_null());
8206 assert_ne!(json["cost"]["input_per_mtok"], serde_json::json!(0.0));
8207 }
8208
8209 #[test]
8210 fn a_hand_registered_copy_of_a_curated_id_does_not_double_the_row() {
8211 let mut reg = test_registry();
8212 let id = "openrouter/anthropic/claude-opus-4.8";
8213 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
8214
8215 let mut copy = reg
8218 .list()
8219 .into_iter()
8220 .find(|m| m.id == id)
8221 .cloned()
8222 .expect("curated row");
8223 copy.name = "hand-registered".into();
8224 reg.register_user_model(copy);
8225 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
8226 }
8227
8228 #[test]
8236 fn managed_alias_publishes_prices_without_disclosing_the_upstream_id_in_the_catalog_view() {
8237 let reg = test_registry();
8238 let alias = reg
8239 .list()
8240 .into_iter()
8241 .find(|m| m.id == "parslee/openrouter/frontier-deep-next")
8242 .map(ModelInfo::from)
8243 .expect("managed alias for the new curated row");
8244
8245 assert_eq!(alias.cost.input_per_mtok, Some(5.0));
8246 assert_eq!(alias.cost.output_per_mtok, Some(25.0));
8247 assert_eq!(alias.cost.cache_read_input_per_mtok, Some(0.5));
8248 assert_eq!(alias.cost.cache_write_input_per_mtok, Some(6.25));
8249
8250 let wire = serde_json::to_string(&alias).unwrap();
8251 assert!(!wire.contains("claude-opus-4.8"));
8252 assert!(!wire.contains("anthropic/"));
8253 }
8254
8255 #[test]
8256 fn model_info_from_an_older_daemon_without_cost_still_parses() {
8257 let legacy = serde_json::json!({
8260 "id": "legacy/model",
8261 "name": "legacy",
8262 "provider": "legacy",
8263 "capabilities": ["generate"],
8264 "param_count": "",
8265 "size_mb": 0,
8266 "context_length": 8192,
8267 "available": true,
8268 "is_local": false
8269 });
8270 let info: ModelInfo = serde_json::from_value(legacy).expect("older catalog row parses");
8271 assert!(info.cost.input_per_mtok.is_none());
8272 assert!(info.cost.output_per_mtok.is_none());
8273 assert!(info.cost.pricing_tiers.is_empty());
8274 assert!(info.max_output_tokens.is_none());
8275 assert!(info.car_enabled, "legacy rows default to enabled");
8276 assert!(!info.can_remove);
8277 assert!(!info.in_use);
8278 assert!(info.management_evidence.is_none());
8279 }
8280
8281 #[test]
8282 fn visual_generation_models_are_curated() {
8283 let reg = test_registry();
8284 assert_eq!(
8285 reg.query_by_capability(ModelCapability::ImageGeneration)
8286 .len(),
8287 1
8288 );
8289 assert_eq!(
8290 reg.query_by_capability(ModelCapability::VideoGeneration)
8291 .len(),
8292 1
8293 );
8294 }
8295}
8296
8297#[cfg(test)]
8306mod builtin_catalog_validation {
8307 use super::*;
8308 use crate::schema::ModelSource;
8309
8310 fn weight_repo(source: &ModelSource) -> Option<&str> {
8312 match source {
8313 ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
8314 ModelSource::Local { hf_repo, .. } => Some(hf_repo),
8315 ModelSource::ManagedVllmMlx { hf_repo, .. } => Some(hf_repo),
8316 _ => None,
8317 }
8318 }
8319
8320 #[test]
8321 fn ids_are_unique() {
8322 let catalog = builtin_catalog();
8323 let mut seen: Vec<&str> = Vec::new();
8324 for model in &catalog {
8325 assert!(
8326 !seen.contains(&model.id.as_str()),
8327 "duplicate catalog id `{}` — the later entry silently shadows the earlier",
8328 model.id
8329 );
8330 seen.push(&model.id);
8331 }
8332 }
8333
8334 #[test]
8335 fn source_kind_is_the_serialized_source_tag() {
8336 for model in builtin_catalog() {
8337 let tagged = serde_json::to_value(&model.source).unwrap();
8338 assert_eq!(
8339 tagged["type"].as_str(),
8340 Some(model.source.kind()),
8341 "{}",
8342 model.id
8343 );
8344 assert_eq!(
8345 crate::ModelInfo::from(&model).source_kind,
8346 model.source.kind()
8347 );
8348 }
8349 let codex = builtin_catalog()
8350 .into_iter()
8351 .find(|model| model.id == "openai/gpt-5.6-sol:high")
8352 .unwrap();
8353 let api = builtin_catalog()
8354 .into_iter()
8355 .find(|model| model.id == "openai/gpt-5.6-sol:latest")
8356 .unwrap();
8357 assert_eq!(crate::ModelInfo::from(&codex).source_kind, "codex_cli");
8358 assert_eq!(crate::ModelInfo::from(&api).source_kind, "remote_api");
8359 }
8360
8361 #[test]
8362 fn exact_frontier_rows_lock_native_selectors_and_digests() {
8363 let catalog = builtin_catalog();
8364 for (id, name, version, expected_digest) in [
8365 (
8366 "openai/gpt-5.5-2026-04-23",
8367 "gpt-5.5-2026-04-23",
8368 "2026-04-23",
8369 "5b2daeae9d9115bab030e4a0630402340198ef56aaf839d6d04f22b9a1bd8eb6",
8370 ),
8371 (
8372 "anthropic/claude-opus-4-8",
8373 "claude-opus-4-8",
8374 "4.8",
8375 "f430356ae863eae3e6509443f94099d6588dba838f0a2214e574d731c8f6ed82",
8376 ),
8377 (
8378 "openai/gpt-5.6-sol:high",
8379 "gpt-5.6-sol:high",
8380 "latest",
8381 "529c449ccc425384a3af7068fca9b9a42d040f0c34cb1275dcd831c2f42e93f7",
8382 ),
8383 ] {
8384 let row = catalog
8385 .iter()
8386 .find(|model| model.id == id)
8387 .unwrap_or_else(|| panic!("missing exact production row {id}"));
8388 assert_eq!(row.name, name);
8389 assert_eq!(row.version, version);
8390 assert_eq!(
8391 crate::catalog_identity::row_digest(row).unwrap(),
8392 expected_digest,
8393 "CAR row digest drifted for {id}"
8394 );
8395 }
8396 }
8397
8398 #[test]
8399 fn weight_repos_are_well_formed_huggingface_ids() {
8400 for model in builtin_catalog() {
8401 let Some(repo) = weight_repo(&model.source) else {
8402 continue;
8403 };
8404 assert_eq!(
8405 repo.split('/').count(),
8406 2,
8407 "{}: `{repo}` is not an `org/name` HuggingFace id",
8408 model.id
8409 );
8410 assert!(
8411 !repo.split('/').any(str::is_empty),
8412 "{}: `{repo}` has an empty path segment",
8413 model.id
8414 );
8415 assert!(
8416 !repo.contains(char::is_whitespace),
8417 "{}: `{repo}` contains whitespace",
8418 model.id
8419 );
8420 }
8421 }
8422
8423 #[test]
8427 fn param_counts_are_parseable_or_deliberately_empty() {
8428 for model in builtin_catalog() {
8429 if weight_repo(&model.source).is_none() || model.param_count.is_empty() {
8430 continue;
8431 }
8432 assert!(
8433 model.param_count.starts_with(|c: char| c.is_ascii_digit()),
8434 "{}: param_count `{}` does not start with a number, so the quality \
8435 prior cannot read it — leave it empty rather than descriptive",
8436 model.id,
8437 model.param_count
8438 );
8439 }
8440 }
8441
8442 #[test]
8445 fn catalog_vllm_mlx_entries_use_explicit_managed_ownership() {
8446 for model in builtin_catalog() {
8447 if !model.is_vllm_mlx() {
8448 continue;
8449 }
8450 assert!(
8451 model.is_car_managed_vllm_mlx(),
8452 "{}: a CAR-supervised catalog row must opt into ManagedVllmMlx; \
8453 loopback alone cannot confer ownership",
8454 model.id
8455 );
8456 }
8457 }
8458
8459 #[test]
8460 fn generate_capable_models_declare_a_context_window() {
8461 for model in builtin_catalog() {
8462 if !model.has_capability(crate::schema::ModelCapability::Generate) {
8463 continue;
8464 }
8465 assert!(
8466 model.context_length > 0,
8467 "{}: a generate-capable model with no context_length breaks budget sizing",
8468 model.id
8469 );
8470 }
8471 }
8472
8473 #[test]
8474 fn every_entry_declares_at_least_one_capability() {
8475 for model in builtin_catalog() {
8476 assert!(
8477 !model.capabilities.is_empty(),
8478 "{}: an entry with no capabilities can never be routed to",
8479 model.id
8480 );
8481 }
8482 }
8483}
8484
8485#[cfg(test)]
8486mod gguf_quantization_tests {
8487 use crate::schema::{QuantScheme, Quantization};
8488
8489 fn quantization_from_gguf_filename(name: &str) -> Option<Quantization> {
8490 Quantization::from_gguf_filename(name)
8491 }
8492
8493 #[test]
8494 fn reads_the_quantization_a_gguf_file_names() {
8495 let cases = [
8496 ("Qwen3-8B-Q4_K_M.gguf", "Q4_K_M", QuantScheme::KQuantMixed),
8497 (
8498 "Qwen3-Embedding-0.6B-Q8_0.gguf",
8499 "Q8_0",
8500 QuantScheme::RtnBlock,
8501 ),
8502 (
8503 "ggml-large-v3-turbo-q5_0.gguf",
8504 "q5_0",
8505 QuantScheme::RtnBlock,
8506 ),
8507 ("model-IQ4_XS.gguf", "IQ4_XS", QuantScheme::KQuantMixed),
8508 ];
8509 for (filename, label, scheme) in cases {
8510 let q = quantization_from_gguf_filename(filename)
8511 .unwrap_or_else(|| panic!("no quantization found in {filename}"));
8512 assert_eq!(q.label, label, "label for {filename}");
8513 assert_eq!(q.scheme, scheme, "scheme for {filename}");
8514 }
8515 }
8516
8517 #[test]
8519 fn returns_none_when_the_name_says_nothing() {
8520 for filename in ["model.gguf", "llama-2-7b-chat.gguf", "ggml-base.gguf"] {
8521 assert!(
8522 quantization_from_gguf_filename(filename).is_none(),
8523 "should not have guessed from {filename}"
8524 );
8525 }
8526 }
8527
8528 #[test]
8531 fn the_rightmost_match_wins() {
8532 let q = quantization_from_gguf_filename("q8-experiment-Q4_K_M.gguf").unwrap();
8533 assert_eq!(q.label, "Q4_K_M");
8534 }
8535}
8536
8537#[cfg(test)]
8538mod mlx_executor_discrimination_tests {
8539 use super::*;
8540
8541 #[test]
8553 fn only_in_process_text_rows_need_the_swift_loader() {
8554 let catalog = builtin_catalog();
8555 let row = |id: &str| {
8556 catalog
8557 .iter()
8558 .find(|m| m.id == id)
8559 .unwrap_or_else(|| panic!("{id} missing from the builtin catalog"))
8560 };
8561
8562 for id in [
8563 "mlx/flux-1-lite-8b:q4", "mlx/ltx-2.3:q4", "mlx-vlm/qwen3-vl-2b:bf16", "mlx/kokoro-82m:bf16", "mlx/parakeet-tdt-0.6b-v3:default",
8568 ] {
8569 let m = row(id);
8570 assert!(
8571 matches!(m.source, ModelSource::Mlx { .. }),
8572 "{id}: precondition — this test is only meaningful for Mlx-sourced rows"
8573 );
8574 assert!(
8575 !mlx_row_needs_in_process_loader(m),
8576 "{id} runs in a subprocess; its availability must not depend on the \
8577 in-process Swift loader"
8578 );
8579 }
8580
8581 for id in ["mlx/qwen3-4b:4bit", "mlx/gemma-4-12b-it:4bit"] {
8584 assert!(
8585 mlx_row_needs_in_process_loader(row(id)),
8586 "{id} is served in-process and does need the loader"
8587 );
8588 }
8589 }
8590}
8591
8592#[cfg(test)]
8593mod local_availability_tests {
8594 use super::*;
8595 use crate::schema::ModelSchema;
8596 use tempfile::TempDir;
8597
8598 fn gguf_row(id: &str, hf_repo: &str) -> ModelSchema {
8599 let mut schema: ModelSchema = serde_json::from_value(serde_json::json!({
8600 "id": id,
8601 "name": id.replace('/', "-"),
8602 "provider": "qwen",
8603 "family": "qwen3",
8604 "capabilities": ["generate"],
8605 "context_length": 32768,
8606 "param_count": "8B",
8607 "source": {
8608 "type": "local",
8609 "hf_repo": hf_repo,
8610 "hf_filename": "model.gguf",
8611 "tokenizer_repo": hf_repo,
8612 },
8613 "cost": { "size_mb": 4900 },
8614 }))
8615 .unwrap();
8616 schema.available = false;
8617 schema
8618 }
8619
8620 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
8631 #[test]
8632 fn a_declared_repo_is_available_before_it_is_downloaded() {
8633 let _environment = crate::openrouter::test_environment_scope();
8634 let tmp = TempDir::new().unwrap();
8635 let models = tmp.path().join("models");
8636 std::fs::create_dir_all(&models).unwrap();
8637
8638 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
8639 reg.register(gguf_row("qwen/test-8b:q4_k_m", "Qwen/Qwen3-8B-GGUF"));
8640
8641 let model = reg.get("qwen/test-8b:q4_k_m").expect("registered");
8642 assert!(
8643 model.available,
8644 "a GGUF model with a repo to fetch from must not report unavailable \
8645 just because nothing has downloaded it yet"
8646 );
8647 }
8648
8649 #[test]
8653 fn a_row_with_nowhere_to_fetch_from_stays_unavailable() {
8654 let _environment = crate::openrouter::test_environment_scope();
8655 let tmp = TempDir::new().unwrap();
8656 let models = tmp.path().join("models");
8657 std::fs::create_dir_all(&models).unwrap();
8658
8659 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
8660 reg.register(gguf_row("local/scanned", ""));
8661
8662 let model = reg.get("local/scanned").expect("registered");
8663 assert!(
8664 !model.available,
8665 "an empty hf_repo has no download to promise"
8666 );
8667 }
8668
8669 #[test]
8670 fn builtin_credential_names_cover_remote_and_proprietary_auth() {
8671 let names = builtin_credential_env_names();
8672 for name in [
8673 "OPENAI_API_KEY",
8674 "ANTHROPIC_API_KEY",
8675 crate::openrouter::API_KEY_ENV,
8676 ] {
8677 assert!(names.contains(name), "built-in catalog omitted {name}");
8678 }
8679 }
8680}