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)))]
1413 pub fn resolve_mlx_equivalent(&self, schema: &ModelSchema) -> Option<&ModelSchema> {
1414 if schema.is_mlx() || schema.is_vllm_mlx() {
1416 return None;
1417 }
1418 if !matches!(schema.source, ModelSource::Local { .. }) {
1420 return None;
1421 }
1422 let primary_cap = schema.capabilities.first()?;
1429 let twin = self
1430 .models
1431 .values()
1432 .filter(|m| {
1433 m.is_mlx()
1434 && m.family == schema.family
1435 && m.param_count == schema.param_count
1436 && m.capabilities.contains(primary_cap)
1437 })
1438 .min_by(|a, b| (a.deprecated, &a.id).cmp(&(b.deprecated, &b.id)))?;
1439 (!twin.deprecated || schema.deprecated).then_some(twin)
1440 }
1441
1442 pub async fn ensure_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1444 let sink = self.ambient_progress.clone();
1449 self.ensure_local_with_progress(id, &sink).await
1450 }
1451
1452 pub fn set_ambient_progress(&mut self, sink: ProgressSink) {
1459 self.ambient_progress = sink;
1460 }
1461
1462 pub async fn ensure_local_with_progress(
1469 &self,
1470 id: &str,
1471 sink: &ProgressSink,
1472 ) -> Result<PathBuf, InferenceError> {
1473 self.acquire_and_ensure(id, sink, false, None).await
1474 }
1475
1476 pub(crate) async fn ensure_local_with_progress_staged(
1481 &self,
1482 id: &str,
1483 sink: &ProgressSink,
1484 staging_dir: &Path,
1485 ) -> Result<PathBuf, InferenceError> {
1486 self.acquire_and_ensure(id, sink, false, Some(staging_dir))
1487 .await
1488 }
1489
1490 pub async fn redownload_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1496 self.acquire_and_ensure(id, &ProgressSink::none(), true, None)
1497 .await
1498 }
1499
1500 async fn acquire_and_ensure(
1501 &self,
1502 id: &str,
1503 sink: &ProgressSink,
1504 force: bool,
1505 managed_dir_override: Option<&Path>,
1506 ) -> Result<PathBuf, InferenceError> {
1507 let schema = self
1508 .get(id)
1509 .or_else(|| self.find_by_name(id))
1510 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1511 if never_download_in_scope() {
1516 if !force && managed_dir_override.is_none() {
1517 if let Some(path) = self.try_reuse_local(schema).await? {
1518 return Ok(path);
1519 }
1520 }
1521 return Err(refuse_download(schema));
1522 }
1523 let model_name = schema.name.clone();
1524 let model_id = schema.id.clone();
1525 let needed_mb = schema.size_mb();
1526 let model_dir = self.models_dir.join(&schema.name);
1527
1528 let _guard = crate::download::acquire_model_lock(&model_id).await;
1530
1531 if !force && managed_dir_override.is_none() {
1535 if let Some(path) = self.try_reuse_local(schema).await? {
1536 return Ok(path);
1537 }
1538 }
1539
1540 if let Err(e) = crate::download::check_disk_space(&model_dir, needed_mb) {
1542 sink.emit(DownloadEvent::Failed { error: e.clone() });
1543 return Err(InferenceError::DownloadFailed(e));
1544 }
1545
1546 sink.emit(DownloadEvent::Started {
1547 model: model_name.clone(),
1548 total_files: 0,
1549 total_mb: needed_mb,
1550 });
1551 let (result, fetched) = crate::retire::collecting_fetches(self.ensure_local_inner(
1554 id,
1555 sink,
1556 force,
1557 managed_dir_override,
1558 ))
1559 .await;
1560 crate::retire::record_download(
1561 &self.state_root,
1562 &crate::hf_cache::hub_dir(),
1563 &model_id,
1564 &fetched,
1565 );
1566 match &result {
1567 Ok(_) => sink.emit(DownloadEvent::Completed { model: model_name }),
1568 Err(e) => sink.emit(DownloadEvent::Failed {
1569 error: e.to_string(),
1570 }),
1571 }
1572 result
1573 }
1574
1575 async fn try_reuse_local(
1576 &self,
1577 schema: &ModelSchema,
1578 ) -> Result<Option<PathBuf>, InferenceError> {
1579 let reused = self.reusable_local_dir(schema);
1580 if let Some(path) = &reused {
1581 info!(model = %schema.name, path = %path.display(), "using local model files already on disk");
1582 }
1583 Ok(reused)
1584 }
1585
1586 fn reusable_local_dir(&self, schema: &ModelSchema) -> Option<PathBuf> {
1589 match &schema.source {
1590 ModelSource::Local { .. } => {
1591 let model_dir = self.models_dir.join(&schema.name);
1592 let model_path = model_dir.join("model.gguf");
1593 let tokenizer_path = model_dir.join("tokenizer.json");
1594
1595 if crate::download::cache_file_usable(&model_path)
1596 && crate::download::cache_file_usable(&tokenizer_path)
1597 {
1598 return Some(model_dir);
1599 }
1600 }
1601 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1602 let model_dir = self.models_dir.join(&schema.name);
1603 let is_diffusers = mlx_schema_is_diffusers(schema);
1604
1605 if mlx_layout_complete(is_diffusers, &model_dir) {
1606 if auxiliary_mlx_files_missing(&schema.name, hf_repo, &model_dir) {
1607 return None;
1611 }
1612 return Some(model_dir);
1613 }
1614
1615 if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1616 .filter(|d| mlx_layout_complete(is_diffusers, d))
1617 {
1618 if !auxiliary_mlx_files_missing(&schema.name, hf_repo, &snapshot_dir) {
1619 return Some(snapshot_dir);
1620 }
1621 }
1622 }
1623 _ => {}
1624 }
1625
1626 None
1627 }
1628
1629 async fn ensure_local_inner(
1630 &self,
1631 id: &str,
1632 sink: &ProgressSink,
1633 force: bool,
1634 managed_dir_override: Option<&Path>,
1635 ) -> Result<PathBuf, InferenceError> {
1636 let schema = self
1637 .get(id)
1638 .or_else(|| self.find_by_name(id))
1639 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1640
1641 match &schema.source {
1642 ModelSource::Local {
1643 hf_repo,
1644 hf_filename,
1645 tokenizer_repo,
1646 } => {
1647 let model_dir = managed_dir_override
1648 .map(Path::to_path_buf)
1649 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1650 let model_path = model_dir.join("model.gguf");
1651 let tokenizer_path = model_dir.join("tokenizer.json");
1652
1653 if !force
1654 && crate::download::cache_file_usable(&model_path)
1655 && crate::download::cache_file_usable(&tokenizer_path)
1656 {
1657 return Ok(model_dir);
1658 }
1659
1660 if hf_repo.is_empty() {
1667 let missing = [
1668 ("model.gguf", &model_path),
1669 ("tokenizer.json", &tokenizer_path),
1670 ]
1671 .into_iter()
1672 .filter(|(_, path)| !crate::download::cache_file_usable(path))
1673 .map(|(name, _)| name)
1674 .collect::<Vec<_>>()
1675 .join(" and ");
1676 return Err(InferenceError::InferenceFailed(format!(
1677 "{}: discovered on disk at {} but not loadable — the GGUF \
1678 backend reads `model.gguf` and `tokenizer.json` from the \
1679 model directory, and this one is missing {missing}. Rename \
1680 the weight file to `model.gguf` and add the tokenizer, or \
1681 register the model against its HuggingFace repo so CAR can \
1682 fetch both.",
1683 schema.name,
1684 model_dir.display()
1685 )));
1686 }
1687
1688 std::fs::create_dir_all(&model_dir)?;
1689
1690 if !crate::download::cache_file_usable(&model_path) {
1691 info!(model = %schema.name, repo = %hf_repo, "downloading model weights");
1692 sink.emit(DownloadEvent::FileStarted {
1693 filename: "model weights".into(),
1694 index: 1,
1695 total_files: 2,
1696 size_mb: schema.size_mb(),
1697 });
1698 download_file(hf_repo, hf_filename, &model_path).await?;
1699 sink.emit(DownloadEvent::FileCompleted {
1700 filename: "model weights".into(),
1701 });
1702 }
1703 if !crate::download::cache_file_usable(&tokenizer_path) {
1704 info!(model = %schema.name, repo = %tokenizer_repo, "downloading tokenizer");
1705 sink.emit(DownloadEvent::FileStarted {
1706 filename: "tokenizer".into(),
1707 index: 2,
1708 total_files: 2,
1709 size_mb: 0,
1710 });
1711 download_file(tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
1712 sink.emit(DownloadEvent::FileCompleted {
1713 filename: "tokenizer".into(),
1714 });
1715 }
1716
1717 Ok(model_dir)
1718 }
1719 ModelSource::Mlx {
1720 hf_repo,
1721 hf_weight_file,
1722 }
1723 | ModelSource::ManagedVllmMlx {
1724 hf_repo,
1725 hf_weight_file,
1726 ..
1727 } => {
1728 let model_dir = managed_dir_override
1729 .map(Path::to_path_buf)
1730 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1731 let config_path = model_dir.join("config.json");
1732
1733 let is_diffusers = mlx_schema_is_diffusers(schema);
1744
1745 if !force && mlx_layout_complete(is_diffusers, &model_dir) {
1756 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1757 info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1758 return Ok(model_dir);
1759 }
1760
1761 if !force {
1765 if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1766 .filter(|d| mlx_layout_complete(is_diffusers, d))
1767 {
1768 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &snapshot_dir).await?;
1769 info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1770 return Ok(snapshot_dir);
1771 }
1772 }
1773
1774 std::fs::create_dir_all(&model_dir)?;
1775
1776 info!(model = %schema.name, repo = %hf_repo, "downloading MLX model");
1777
1778 if is_diffusers {
1785 download_repo_snapshot(hf_repo, &model_dir, sink).await?;
1786 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1787 if !mlx_dir_has_weights(&model_dir) {
1788 return Err(InferenceError::DownloadFailed(format!(
1789 "{hf_repo}: snapshot fetched but no component weights found"
1790 )));
1791 }
1792 info!(model = %schema.name, path = %model_dir.display(), "downloaded diffusers model");
1793 return Ok(model_dir);
1794 }
1795
1796 emit_file(sink, "config", 0, schema.size_mb());
1800 download_file(hf_repo, "config.json", &config_path).await?;
1801 download_tokenizer_assets(hf_repo, &model_dir, sink).await;
1802 let tok_config_path = model_dir.join("tokenizer_config.json");
1803 if !crate::download::cache_file_usable(&tok_config_path) {
1804 let _ = download_file(hf_repo, "tokenizer_config.json", &tok_config_path).await;
1805 }
1806
1807 if let Some(ref wf) = hf_weight_file {
1809 let wf_path = model_dir.join(wf);
1810 if !crate::download::cache_file_usable(&wf_path) {
1811 emit_file(sink, "model weights", 0, schema.size_mb());
1812 download_file(hf_repo, wf, &wf_path).await?;
1813 }
1814 } else {
1815 let single = model_dir.join("model.safetensors");
1817 if !crate::download::cache_file_usable(&single) {
1818 emit_file(sink, "model weights", 0, schema.size_mb());
1819 match download_file(hf_repo, "model.safetensors", &single).await {
1820 Ok(()) => {}
1821 Err(_) => {
1822 let index_path = model_dir.join("model.safetensors.index.json");
1824 download_file(hf_repo, "model.safetensors.index.json", &index_path)
1825 .await?;
1826
1827 let index_json: serde_json::Value =
1828 serde_json::from_str(&std::fs::read_to_string(&index_path)?)
1829 .map_err(|e| {
1830 InferenceError::InferenceFailed(format!(
1831 "parse index: {e}"
1832 ))
1833 })?;
1834
1835 if let Some(weight_map) =
1836 index_json.get("weight_map").and_then(|m| m.as_object())
1837 {
1838 let mut files: std::collections::HashSet<String> =
1839 std::collections::HashSet::new();
1840 for filename in weight_map.values() {
1841 if let Some(f) = filename.as_str() {
1842 files.insert(f.to_string());
1843 }
1844 }
1845 let shard_total = files.len() as u32;
1846 for (i, file) in files.iter().enumerate() {
1847 let dest = model_dir.join(file);
1848 if !crate::download::cache_file_usable(&dest) {
1849 info!(file = %file, "downloading weight shard");
1850 sink.emit(DownloadEvent::FileStarted {
1851 filename: format!("weights part {}", i + 1),
1852 index: (i + 1) as u32,
1853 total_files: shard_total,
1854 size_mb: 0,
1855 });
1856 download_file(hf_repo, file, &dest).await?;
1857 sink.emit(DownloadEvent::FileCompleted {
1858 filename: format!("weights part {}", i + 1),
1859 });
1860 }
1861 }
1862 }
1863 }
1864 }
1865 }
1866 }
1867
1868 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1869
1870 let missing = missing_weight_shards(&model_dir);
1882 if !missing.is_empty() {
1883 return Err(InferenceError::DownloadFailed(format!(
1884 "{}: pull finished but {} weight shard(s) are still missing: {}. \
1885 The download was interrupted; re-run the pull to resume it.",
1886 schema.name,
1887 missing.len(),
1888 missing.join(", ")
1889 )));
1890 }
1891 if !mlx_dir_has_weights(&model_dir) {
1892 return Err(InferenceError::DownloadFailed(format!(
1893 "{}: pull finished but no usable weights are present under {}",
1894 schema.name,
1895 model_dir.display()
1896 )));
1897 }
1898 Ok(model_dir)
1899 }
1900 _ => Err(InferenceError::InferenceFailed(format!(
1901 "model {} is not local",
1902 id
1903 ))),
1904 }
1905 }
1906
1907 #[deprecated(note = "use InferenceEngine::remove_model_from_car")]
1911 pub fn remove_local(&mut self, id: &str) -> Result<(), InferenceError> {
1912 Err(InferenceError::InferenceFailed(format!(
1913 "legacy registry removal for {id} is disabled; use receipt-backed model management"
1914 )))
1915 }
1916
1917 pub fn refresh_availability(&mut self) {
1924 let parslee_oauth_available = self.session.available();
1925 self.refresh_availability_with(
1926 parslee_oauth_available,
1927 self.session.signed_out() && self.session.may_forget_session_evidence(),
1928 false,
1929 );
1930 }
1931
1932 pub fn refresh_availability_for_explicit_status(&mut self) {
1950 let parslee_oauth_available = self.session.available();
1951 self.refresh_availability_with(
1952 parslee_oauth_available,
1953 self.session.signed_out() && self.session.may_forget_session_evidence(),
1954 true,
1955 );
1956 }
1957
1958 pub(crate) fn refresh_routing_availability(
1963 &mut self,
1964 parslee_api_base: Option<&str>,
1965 parslee_signed_out: bool,
1966 ) {
1967 if let Some(api_base) = parslee_api_base {
1968 let api_base = api_base.trim_end_matches('/');
1969 for schema in self.models.values_mut() {
1970 if schema.provider.eq_ignore_ascii_case("parslee") {
1971 if let ModelSource::Proprietary {
1972 provider, endpoint, ..
1973 } = &mut schema.source
1974 {
1975 if provider.eq_ignore_ascii_case("parslee") {
1976 *endpoint = api_base.to_string();
1977 }
1978 }
1979 }
1980 }
1981 }
1982 self.refresh_availability_with(parslee_api_base.is_some(), parslee_signed_out, true);
1983 }
1984
1985 pub(crate) fn refresh_work_context_availability(&mut self, api_base: &str, available: bool) {
1988 self.refresh_availability();
1989 for schema in self.models.values_mut() {
1990 if !is_work_context_row(schema) {
1991 continue;
1992 }
1993 if let ModelSource::Proprietary { endpoint, .. } = &mut schema.source {
1994 *endpoint = api_base.trim_end_matches('/').to_string();
1995 schema.available = available;
1996 }
1997 }
1998 }
1999
2000 pub(crate) fn work_context_serves(&self, model: &str) -> bool {
2006 self.get_by_id_or_name(model)
2007 .is_some_and(|schema| !is_work_context_row(schema) || schema.available)
2008 }
2009
2010 pub fn credential_state_is_unknown(&self, env_var: &str) -> bool {
2013 self.credential_state_unknown.contains(env_var)
2014 }
2015
2016 fn record_codex_lookup(&mut self, lookup: Result<bool, String>) {
2021 match lookup {
2022 Ok(present) => remember_codex_sign_in(present),
2023 Err(_) => {
2024 remember_codex_sign_in(false);
2025 self.codex_state_unknown = true;
2026 }
2027 }
2028 }
2029
2030 pub fn row_credential_state_unknown(&self, schema: &ModelSchema) -> bool {
2035 match &schema.source {
2036 ModelSource::RemoteApi {
2037 protocol: crate::schema::ApiProtocol::OpenRouter,
2038 ..
2039 } => self.openrouter_state_unknown,
2040 ModelSource::RemoteApi { api_key_env, .. } => {
2041 self.credential_state_unknown.contains(api_key_env)
2042 }
2043 ModelSource::Proprietary { auth, .. } => match auth {
2044 ProprietaryAuth::ApiKeyEnv { env_var }
2045 | ProprietaryAuth::BearerTokenEnv { env_var } => {
2046 self.credential_state_unknown.contains(env_var)
2047 }
2048 ProprietaryAuth::ChatGptSubscription {} => self.codex_state_unknown,
2049 ProprietaryAuth::OAuth2Pkce { .. } => false,
2050 },
2051 _ => false,
2052 }
2053 }
2054
2055 fn refresh_availability_with(
2056 &mut self,
2057 parslee_oauth_available: bool,
2058 clear_parslee_observations: bool,
2059 authoritative_credentials: bool,
2060 ) {
2061 let models_dir = self.models_dir.clone();
2065 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2070 let mlx_vlm_cli_present = crate::backend::mlx_vlm_cli::is_available();
2071 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2072 #[allow(unused_variables)]
2073 let mlx_vlm_cli_present = false;
2074 if clear_parslee_observations {
2079 crate::openrouter::clear_gateway_unconfigured();
2080 crate::parslee_credential::clear_credential_rejected();
2084 }
2085
2086 let mut credential_envs: std::collections::BTreeSet<String> = Default::default();
2104 let mut needs_openrouter = false;
2105 let mut needs_codex = false;
2106 for m in self.models.values() {
2107 if crate::typesafe::is_direct(m) {
2108 continue; }
2110 match &m.source {
2111 ModelSource::RemoteApi {
2112 protocol: crate::schema::ApiProtocol::OpenRouter,
2113 ..
2114 } => needs_openrouter = true,
2115 ModelSource::RemoteApi { api_key_env, .. } => {
2116 credential_envs.insert(api_key_env.clone());
2117 }
2118 ModelSource::Proprietary { auth, .. } => match auth {
2119 ProprietaryAuth::ApiKeyEnv { env_var }
2120 | ProprietaryAuth::BearerTokenEnv { env_var } => {
2121 credential_envs.insert(env_var.clone());
2122 }
2123 ProprietaryAuth::ChatGptSubscription {} => needs_codex = true,
2124 ProprietaryAuth::OAuth2Pkce { .. } => {}
2125 },
2126 _ => {}
2127 }
2128 }
2129 let mut credential_unknown: std::collections::HashSet<String> =
2130 std::collections::HashSet::new();
2131 self.codex_state_unknown = false;
2138 if authoritative_credentials && needs_codex {
2139 self.record_codex_lookup(car_auth::codex_credential_exists());
2140 }
2141 let credential_available: std::collections::HashMap<String, bool> = credential_envs
2142 .into_iter()
2143 .map(|env| {
2144 let available = if authoritative_credentials {
2145 match credential_presence(&env) {
2149 CredentialPresence::Present => true,
2150 CredentialPresence::Absent => false,
2151 CredentialPresence::Unknown => {
2152 credential_unknown.insert(env.clone());
2153 false
2154 }
2155 }
2156 } else {
2157 environment_credential_available(&env)
2158 };
2159 (env, available)
2160 })
2161 .collect();
2162 self.credential_state_unknown = credential_unknown;
2163 let stored_available: std::collections::HashMap<String, bool> = self
2164 .models
2165 .values()
2166 .filter(|row| crate::typesafe::is_direct(row))
2167 .filter_map(|row| crate::typesafe::key_name(row).ok())
2168 .collect::<std::collections::BTreeSet<_>>()
2169 .into_iter()
2170 .map(|key| {
2171 let available = if authoritative_credentials {
2172 match stored_credential_presence(key) {
2173 CredentialPresence::Present => true,
2174 CredentialPresence::Absent => false,
2175 CredentialPresence::Unknown => {
2176 self.credential_state_unknown.insert(key.to_owned());
2177 false
2178 }
2179 }
2180 } else {
2181 false
2182 };
2183 (key.to_owned(), available)
2184 })
2185 .collect();
2186 self.openrouter_state_unknown = false;
2189 let openrouter_available = needs_openrouter
2190 && if authoritative_credentials {
2191 let (source, unknown) = crate::openrouter::refresh_credential_source_with_state();
2192 self.openrouter_state_unknown = unknown;
2193 source.is_some()
2194 } else {
2195 crate::openrouter::credential_source().is_some()
2196 };
2197
2198 for m in self.models.values_mut() {
2199 let direct = crate::typesafe::is_direct(m);
2200 match &m.source {
2201 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2202 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2211 {
2212 let needs_mlx_vlm = m.tags.iter().any(|t| t == "requires-mlx-vlm");
2219
2220 let in_process_loader_present = !mlx_row_needs_in_process_loader(m)
2228 || crate::backend::local::in_process_loader_available();
2229
2230 m.available = if needs_mlx_vlm {
2231 mlx_vlm_cli_present
2232 } else if m.tags.contains(&"speech".to_string()) {
2233 speech_mlx_available()
2234 } else {
2235 let mlx_dir = models_dir.join(&m.name);
2244 in_process_loader_present
2245 && (mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty())
2246 };
2247 }
2248 #[cfg(not(all(
2249 target_os = "macos",
2250 target_arch = "aarch64",
2251 not(car_skip_mlx)
2252 )))]
2253 {
2254 let _ = hf_repo; m.available = false;
2256 }
2257 }
2258 ModelSource::Local {
2259 hf_repo: local_repo,
2260 ..
2261 } => {
2262 let local_path = models_dir.join(&m.name).join("model.gguf");
2263 #[cfg(not(all(
2278 target_os = "macos",
2279 target_arch = "aarch64",
2280 not(car_skip_mlx)
2281 )))]
2282 {
2283 m.available = local_path.exists() || !local_repo.is_empty();
2284 }
2285 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2290 {
2291 let _ = local_repo;
2292 m.available = local_path.exists();
2293 }
2294 }
2295 ModelSource::WhisperCpp { .. } => {
2296 m.available = true;
2301 }
2302 ModelSource::WindowsSpeech {} => {
2303 #[cfg(target_os = "windows")]
2306 {
2307 m.available = true;
2308 }
2309 #[cfg(not(target_os = "windows"))]
2310 {
2311 m.available = false;
2312 }
2313 }
2314 ModelSource::RemoteApi {
2315 protocol: crate::schema::ApiProtocol::OpenRouter,
2316 ..
2317 } => {
2318 m.available = openrouter_available;
2319 }
2320 ModelSource::RemoteApi { api_key_env, .. } => {
2321 m.available = credential_available
2324 .get(api_key_env)
2325 .copied()
2326 .unwrap_or(false);
2327 }
2328 ModelSource::CodexCli { .. } => {
2329 m.available = crate::backend::codex_cli::is_available();
2330 }
2331 ModelSource::Ollama { .. } => {
2332 m.available = true;
2334 }
2335 ModelSource::VllmMlx { .. } => {
2336 m.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || m.available;
2340 }
2341 ModelSource::Proprietary { provider, auth, .. } => {
2342 m.available = if direct {
2343 crate::typesafe::key_name(m)
2344 .ok()
2345 .and_then(|key| stored_available.get(key))
2346 .copied()
2347 .unwrap_or(false)
2348 } else {
2349 proprietary_auth_available(
2350 &m.id,
2351 &m.provider,
2352 provider,
2353 auth,
2354 parslee_oauth_available,
2355 &credential_available,
2356 )
2357 };
2358 }
2359 ModelSource::AppleFoundationModels { .. } => {
2360 #[cfg(any(
2367 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2368 all(target_os = "ios", target_arch = "aarch64")
2369 ))]
2370 {
2371 m.available = crate::backend::foundation_models::is_available();
2372 if crate::backend::foundation_models::supports_parallel_tool_calls()
2385 && !m.capabilities.contains(&ModelCapability::MultiToolCall)
2386 {
2387 m.capabilities.push(ModelCapability::MultiToolCall);
2388 }
2389 if crate::backend::foundation_models::supports_vision()
2398 && !m.capabilities.contains(&ModelCapability::Vision)
2399 {
2400 m.capabilities.push(ModelCapability::Vision);
2401 }
2402 if let Some(window) = crate::backend::foundation_models::context_size() {
2410 m.context_length = window as usize;
2411 }
2412 }
2413 #[cfg(not(any(
2414 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2415 all(target_os = "ios", target_arch = "aarch64")
2416 )))]
2417 {
2418 m.available = false;
2419 }
2420 }
2421 ModelSource::Delegated { .. } => {
2422 m.available = crate::runner::current_inference_runner().is_some();
2427 }
2428 }
2429 m.weights_ready = physical_weights_ready(m, &models_dir);
2430 }
2431 }
2432
2433 pub fn save_user_config(&self) -> Result<(), InferenceError> {
2435 let mut user_models: Vec<ModelSchema> = self
2436 .user_config_ids
2437 .iter()
2438 .filter_map(|id| self.models.get(id))
2439 .cloned()
2440 .map(|mut model| {
2441 model.mark_user_registered();
2444 model
2445 })
2446 .collect();
2447 user_models.sort_by(|a, b| a.id.cmp(&b.id));
2448
2449 for model in &user_models {
2450 crate::catalog_identity::row_digest(model).map_err(|error| {
2451 InferenceError::InferenceFailed(format!(
2452 "refuse to persist model without canonical catalog identity: {error}"
2453 ))
2454 })?;
2455 }
2456
2457 let json = serde_json::to_string_pretty(&user_models)
2458 .map_err(|e| InferenceError::InferenceFailed(format!("serialize: {e}")))?;
2459 std::fs::write(&self.user_config_path, json)?;
2460 Ok(())
2461 }
2462
2463 pub fn load_user_config(&mut self) -> Result<(), InferenceError> {
2465 if !self.user_config_path.exists() {
2466 return Ok(());
2467 }
2468
2469 let json = std::fs::read_to_string(&self.user_config_path)?;
2470 let models: Vec<ModelSchema> = serde_json::from_str(&json)
2471 .map_err(|e| InferenceError::InferenceFailed(format!("parse models.json: {e}")))?;
2472
2473 for m in models {
2474 self.register_user_model(m);
2477 }
2478 Ok(())
2479 }
2480
2481 pub fn models_dir(&self) -> &Path {
2483 &self.models_dir
2484 }
2485
2486 pub async fn ensure_drafter(
2495 &self,
2496 schema: &ModelSchema,
2497 ) -> Result<Option<PathBuf>, InferenceError> {
2498 let ModelSource::ManagedVllmMlx {
2499 draft_hf_repo: Some(repo),
2500 ..
2501 } = &schema.source
2502 else {
2503 return Ok(None);
2504 };
2505 let dir = drafter_dir(&self.models_dir, schema);
2506 if !mlx_layout_complete(false, &dir) {
2507 if never_download_in_scope() {
2508 return Err(refuse_download(schema));
2509 }
2510 std::fs::create_dir_all(&dir)?;
2511 info!(model = %schema.name, repo = %repo, "downloading speculative-decoding drafter");
2512 download_repo_snapshot(repo, &dir, &self.ambient_progress).await?;
2513 if !mlx_layout_complete(false, &dir) {
2514 return Err(InferenceError::DownloadFailed(format!(
2515 "{repo}: drafter fetched but has no config.json or weights"
2516 )));
2517 }
2518 }
2519 Ok(Some(dir))
2520 }
2521
2522 pub fn ready_without_download(&self, id: &str) -> Option<bool> {
2538 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2539 Some(self.weights_ready_without_download(id)? && self.drafter_ready(schema))
2540 }
2541
2542 pub fn acquires_without_download(&self, id: &str) -> Option<bool> {
2550 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2551 match &schema.source {
2552 ModelSource::Local { .. }
2553 | ModelSource::Mlx { .. }
2554 | ModelSource::ManagedVllmMlx { .. } => {
2555 Some(self.reusable_local_dir(schema).is_some() && self.drafter_ready(schema))
2556 }
2557 _ => self.ready_without_download(id),
2558 }
2559 }
2560
2561 fn drafter_ready(&self, schema: &ModelSchema) -> bool {
2564 match &schema.source {
2565 ModelSource::ManagedVllmMlx {
2566 draft_hf_repo: Some(_),
2567 ..
2568 } => mlx_layout_complete(false, &drafter_dir(&self.models_dir, schema)),
2569 _ => true,
2570 }
2571 }
2572
2573 fn weights_ready_without_download(&self, id: &str) -> Option<bool> {
2576 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2577 Some(match &schema.source {
2578 ModelSource::Local { .. } => {
2579 let model_dir = self.models_dir.join(&schema.name);
2580 crate::download::cache_file_usable(&model_dir.join("model.gguf"))
2581 && crate::download::cache_file_usable(&model_dir.join("tokenizer.json"))
2582 }
2583 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2584 let managed_dir = self.models_dir.join(&schema.name);
2585 let managed_ready = mlx_snapshot_complete(schema, &managed_dir)
2586 && crate::download::cache_file_usable(&managed_dir.join("tokenizer.json"));
2587 let snapshot_ready =
2588 latest_huggingface_repo_snapshot(hf_repo).is_some_and(|snapshot| {
2589 mlx_snapshot_complete(schema, &snapshot)
2590 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2591 });
2592 managed_ready || snapshot_ready
2593 }
2594 ModelSource::WindowsSpeech {} => true, ModelSource::WhisperCpp { model } => {
2596 car_whisper::model_cached(model)
2600 }
2601 ModelSource::RemoteApi { .. }
2602 | ModelSource::CodexCli { .. }
2603 | ModelSource::Ollama { .. }
2604 | ModelSource::VllmMlx { .. }
2605 | ModelSource::AppleFoundationModels { .. }
2606 | ModelSource::Proprietary { .. }
2607 | ModelSource::Delegated { .. } => true,
2608 })
2609 }
2610
2611 pub fn existing_local_artifact(&self, id: &str) -> Option<PathBuf> {
2615 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2616 let managed = self.models_dir.join(&schema.name);
2617 if std::fs::symlink_metadata(&managed).is_ok()
2618 && self.weights_ready_without_download(&schema.id) == Some(true)
2619 {
2620 return Some(managed);
2621 }
2622 match &schema.source {
2623 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2624 latest_huggingface_repo_snapshot(hf_repo).filter(|snapshot| {
2625 mlx_snapshot_complete(schema, snapshot)
2626 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2627 })
2628 }
2629 _ => None,
2630 }
2631 }
2632
2633 fn load_builtin_catalog(&mut self) {
2635 for schema in builtin_catalog() {
2636 let id = schema.id.clone();
2637 if self.register_project_model(schema) {
2638 self.builtin_model_ids.insert(id);
2639 }
2640 }
2641 }
2642}
2643
2644fn synthesize_local_schema(name: &str, dir: &Path) -> Option<ModelSchema> {
2654 let lower = name.to_ascii_lowercase();
2655
2656 const NON_TEXT_HINTS: &[&str] = &[
2659 "vad",
2660 "whisper",
2661 "parakeet",
2662 "kokoro",
2663 "tts",
2664 "stt",
2665 "flux",
2666 "ltx",
2667 "yume",
2668 "sd-",
2669 "stable-diffusion",
2670 "wan",
2671 "mochi",
2672 "sana",
2673 "diffusion",
2674 ];
2675 if NON_TEXT_HINTS.iter().any(|h| lower.contains(h)) {
2676 return None;
2677 }
2678
2679 let capabilities: Vec<ModelCapability> =
2682 if lower.contains("embedding") || lower.contains("embed") {
2683 vec![ModelCapability::Embed]
2684 } else if lower.contains("reranker") || lower.contains("rerank") {
2685 vec![ModelCapability::Rerank]
2686 } else {
2687 vec![
2688 ModelCapability::Generate,
2689 ModelCapability::Code,
2690 ModelCapability::Reasoning,
2691 ]
2692 };
2693
2694 let config_path = dir.join("config.json");
2696 let has_safetensors =
2697 dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists();
2698
2699 let (source, context_length, quantization) = if config_path.exists() && has_safetensors {
2700 let cfg: serde_json::Value = std::fs::read_to_string(&config_path)
2702 .ok()
2703 .and_then(|s| serde_json::from_str(&s).ok())?;
2704 let model_type = cfg
2705 .get("model_type")
2706 .and_then(|v| v.as_str())
2707 .unwrap_or("")
2708 .to_ascii_lowercase();
2709 const KNOWN_LLM_TYPES: &[&str] = &[
2716 "qwen",
2717 "qwen2",
2718 "qwen3",
2719 "qwen3_moe",
2720 "llama",
2721 "mistral",
2722 "mixtral",
2723 "gemma",
2724 "gemma2",
2725 "gemma3",
2726 "gemma4_unified",
2727 "gemma4_unified_text",
2728 "phi",
2729 "phi3",
2730 "phimoe",
2731 "starcoder2",
2732 "deepseek",
2733 "deepseek_v2",
2734 "internlm2",
2735 "cohere",
2736 "olmo",
2737 ];
2738 let recognized = KNOWN_LLM_TYPES.iter().any(|t| model_type == *t)
2739 || crate::backend::local::has_native_backend(&model_type);
2740 if !recognized {
2741 return None;
2742 }
2743 let ctx = cfg
2744 .get("max_position_embeddings")
2745 .and_then(|v| v.as_u64())
2746 .unwrap_or(32_768) as usize;
2747 let quant = cfg
2753 .get("quantization")
2754 .filter(|q| q.is_object())
2755 .and_then(|q| {
2756 let bits = q
2757 .get("bits")
2758 .and_then(|b| b.as_u64())
2759 .and_then(|b| u8::try_from(b).ok());
2760 let group_size = q
2761 .get("group_size")
2762 .and_then(|g| g.as_u64())
2763 .and_then(|g| u32::try_from(g).ok());
2764 let mode = q.get("mode").and_then(|m| m.as_str());
2765 crate::schema::Quantization::from_mlx_config(bits, group_size, mode)
2766 });
2767 (
2768 serde_json::json!({ "type": "mlx", "hf_repo": "" }),
2769 ctx,
2770 quant,
2771 )
2772 } else {
2773 let gguf = std::fs::read_dir(dir).ok().and_then(|rd| {
2774 rd.flatten().map(|e| e.path()).find(|p| {
2775 p.extension()
2776 .and_then(|x| x.to_str())
2777 .is_some_and(|x| x.eq_ignore_ascii_case("gguf"))
2778 })
2779 })?;
2780 let filename = gguf
2782 .file_name()
2783 .and_then(|n| n.to_str())
2784 .unwrap_or("model.gguf")
2785 .to_string();
2786 let quant = crate::schema::Quantization::from_gguf_filename(&filename);
2790 (
2791 serde_json::json!({
2792 "type": "local",
2793 "hf_repo": "",
2794 "hf_filename": filename,
2795 "tokenizer_repo": "",
2796 }),
2797 4_096,
2798 quant,
2799 )
2800 };
2801
2802 let id = format!("local/{}", lower.replace(['/', ' '], "-"));
2803 serde_json::from_value(serde_json::json!({
2804 "id": id,
2805 "name": name,
2806 "provider": "local",
2807 "family": "local",
2808 "capabilities": capabilities,
2809 "context_length": context_length,
2810 "quantization": quantization,
2811 "source": source,
2812 "tags": ["auto-discovered"],
2813 "trust_tier": "community",
2814 }))
2815 .ok()
2816}
2817
2818#[allow(dead_code)]
2837fn speech_mlx_available() -> bool {
2838 let runtime_root = speech_runtime_root();
2842 crate::managed_venv::venv_program(&runtime_root, "mlx_audio.stt.generate").exists()
2843 || crate::managed_venv::venv_program(&runtime_root, "mlx_audio.tts.generate").exists()
2844}
2845
2846#[allow(dead_code)]
2848fn speech_runtime_root() -> PathBuf {
2849 if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
2850 if !path.trim().is_empty() {
2851 return PathBuf::from(path);
2852 }
2853 }
2854 std::env::var_os("HOME")
2855 .or_else(|| std::env::var_os("USERPROFILE"))
2856 .map(PathBuf::from)
2857 .unwrap_or_else(|| PathBuf::from("."))
2858 .join(".car")
2859 .join("speech-runtime")
2860}
2861
2862#[derive(Debug, Clone, Serialize, Deserialize)]
2864pub struct ModelInfo {
2865 pub id: String,
2866 pub name: String,
2867 pub provider: String,
2868 pub capabilities: Vec<ModelCapability>,
2869 pub param_count: String,
2870 pub size_mb: u64,
2871 pub context_length: usize,
2872 pub available: bool,
2873 pub is_local: bool,
2874 #[serde(default)]
2878 pub operator_managed_external_runtime: bool,
2879 #[serde(default)]
2887 pub weights_ready: bool,
2888 #[serde(default)]
2901 pub downloads_weights: bool,
2902 #[serde(default)]
2906 pub max_output_tokens: Option<usize>,
2907 #[serde(default)]
2911 pub public_benchmarks: Vec<crate::schema::BenchmarkScore>,
2912 #[serde(default)]
2927 pub cost: crate::schema::CostModel,
2928 #[serde(default = "default_true")]
2929 pub car_enabled: bool,
2930 #[serde(default)]
2931 pub can_remove: bool,
2932 #[serde(default)]
2933 pub in_use: bool,
2934 #[serde(default)]
2935 pub management_evidence: Option<String>,
2936 #[serde(default)]
2947 pub fit: crate::recommend::ModelFitStatus,
2948 #[serde(default)]
2951 pub estimated_peak_mb: Option<u64>,
2952 #[serde(default = "default_true")]
2961 pub platform_compatible: bool,
2962 #[serde(default)]
2966 pub deprecated: bool,
2967 #[serde(default)]
3005 pub credential_required: Option<String>,
3006 #[serde(default)]
3008 pub unavailable_reason: Option<String>,
3009 #[serde(default)]
3015 pub family: Option<String>,
3016 #[serde(default)]
3020 pub version: Option<String>,
3021 #[serde(default)]
3027 pub source_kind: String,
3028}
3029
3030pub fn model_unavailable_reason(info: &ModelInfo) -> Option<&'static str> {
3033 (!info.available && info.provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER))
3034 .then_some(crate::schema::OPENAI_CODEX_SIGN_IN_HINT)
3035}
3036
3037fn default_true() -> bool {
3038 true
3039}
3040
3041impl ModelInfo {
3042 pub fn with_fit(mut self, fit: crate::recommend::ModelFit) -> Self {
3046 self.fit = fit.fit;
3047 self.estimated_peak_mb = fit.estimated_peak_mb;
3048 self.platform_compatible = fit.platform_compatible;
3049 self
3050 }
3051}
3052
3053impl From<&ModelSchema> for ModelInfo {
3054 fn from(s: &ModelSchema) -> Self {
3055 let mut info = ModelInfo {
3056 id: s.id.clone(),
3057 name: s.name.clone(),
3058 provider: s.provider.clone(),
3059 capabilities: s.capabilities.clone(),
3060 param_count: s.param_count.clone(),
3061 size_mb: s.size_mb(),
3062 context_length: s.context_length,
3063 available: s.available_now(),
3064 is_local: s.is_local(),
3065 operator_managed_external_runtime: matches!(s.source, ModelSource::VllmMlx { .. }),
3066 weights_ready: s.weights_ready,
3067 downloads_weights: s.downloads_weights(),
3068 max_output_tokens: s.max_output_tokens,
3069 public_benchmarks: s.public_benchmarks.clone(),
3070 cost: s.cost.clone(),
3075 car_enabled: true,
3076 can_remove: false,
3077 in_use: false,
3078 management_evidence: None,
3079 fit: crate::recommend::ModelFitStatus::Unknown,
3084 estimated_peak_mb: None,
3085 platform_compatible: true,
3086 credential_required: s.credential_required(),
3087 unavailable_reason: None,
3088 deprecated: s.deprecated,
3089 family: s.is_local().then(|| s.family.clone()),
3092 version: s.is_local().then(|| s.version.clone()),
3093 source_kind: s.source.kind().to_string(),
3094 };
3095 info.unavailable_reason = model_unavailable_reason(&info).map(str::to_owned);
3096 info
3097 }
3098}
3099
3100fn emit_file(sink: &ProgressSink, name: &str, index: u32, size_mb: u64) {
3105 sink.emit(DownloadEvent::FileStarted {
3106 filename: name.to_string(),
3107 index,
3108 total_files: 0,
3109 size_mb,
3110 });
3111}
3112
3113async fn download_repo_snapshot(
3122 repo: &str,
3123 model_dir: &Path,
3124 sink: &ProgressSink,
3125) -> Result<(), InferenceError> {
3126 refuse_fetch_when_never_download(repo)?;
3127 #[derive(serde::Deserialize)]
3128 struct RepoInfo {
3129 siblings: Vec<Sibling>,
3130 }
3131 #[derive(serde::Deserialize)]
3132 struct Sibling {
3133 rfilename: String,
3134 }
3135 let url = format!("{}/api/models/{repo}", crate::hf_cache::endpoint());
3136 let info: RepoInfo = crate::tls_client::model_download_client()
3141 .get(&url)
3142 .send()
3143 .await
3144 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
3145 .error_for_status()
3146 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
3147 .json()
3148 .await
3149 .map_err(|e| InferenceError::DownloadFailed(format!("parse {repo} file list: {e}")))?;
3150
3151 let files: Vec<String> = info
3152 .siblings
3153 .into_iter()
3154 .map(|s| s.rfilename)
3155 .filter(|f| !f.starts_with('.') && !f.to_ascii_lowercase().ends_with(".md"))
3156 .collect();
3157 if files.is_empty() {
3158 return Err(InferenceError::DownloadFailed(format!(
3159 "{repo}: repo lists no downloadable files"
3160 )));
3161 }
3162
3163 let total = files.len() as u32;
3164 for (i, fname) in files.iter().enumerate() {
3165 let dest = model_dir.join(fname);
3166 if crate::download::cache_file_usable(&dest) {
3167 continue;
3168 }
3169 if let Some(parent) = dest.parent() {
3170 std::fs::create_dir_all(parent)?;
3171 }
3172 sink.emit(DownloadEvent::FileStarted {
3173 filename: fname.clone(),
3174 index: (i + 1) as u32,
3175 total_files: total,
3176 size_mb: 0,
3177 });
3178 download_file(repo, fname, &dest).await?;
3179 sink.emit(DownloadEvent::FileCompleted {
3180 filename: fname.clone(),
3181 });
3182 }
3183 Ok(())
3184}
3185
3186pub(crate) const TOKENIZER_FILENAMES: &[&str] = &[
3197 "tokenizer.json",
3198 "vocab.json",
3199 "merges.txt",
3200 "tokenizer.model",
3201 "tokenizer.vocab",
3202 "vocab.txt",
3203];
3204
3205async fn download_tokenizer_assets(hf_repo: &str, model_dir: &Path, sink: &ProgressSink) {
3225 if TOKENIZER_FILENAMES
3227 .iter()
3228 .any(|f| crate::download::cache_file_usable(&model_dir.join(f)))
3229 {
3230 return;
3231 }
3232 emit_file(sink, "tokenizer", 0, 0);
3233 let mut fetched: Vec<&str> = Vec::new();
3234 for name in TOKENIZER_FILENAMES {
3235 let dest = model_dir.join(name);
3236 if crate::download::cache_file_usable(&dest) {
3237 continue;
3238 }
3239 if download_file(hf_repo, name, &dest).await.is_ok() {
3240 fetched.push(name);
3241 }
3242 }
3243 if fetched.is_empty() {
3244 tracing::debug!(
3246 repo = %hf_repo,
3247 "no tokenizer assets in this repo; continuing (the backend may not need one)"
3248 );
3249 } else {
3250 tracing::debug!(repo = %hf_repo, files = ?fetched, "fetched tokenizer assets");
3251 }
3252}
3253
3254async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
3255 fetch_repo_file(repo, filename, dest)
3256 .await
3257 .map_err(FileFetchError::into_inference)
3258}
3259
3260enum FileFetchError {
3262 Absent(String),
3265 Failed(InferenceError),
3267}
3268
3269impl FileFetchError {
3270 fn into_inference(self) -> InferenceError {
3271 match self {
3272 Self::Absent(message) => InferenceError::DownloadFailed(message),
3273 Self::Failed(error) => error,
3274 }
3275 }
3276}
3277
3278impl From<InferenceError> for FileFetchError {
3279 fn from(error: InferenceError) -> Self {
3280 Self::Failed(error)
3281 }
3282}
3283
3284fn hf_file_absent(error: &hf_hub::api::tokio::ApiError) -> bool {
3288 matches!(
3289 error,
3290 hf_hub::api::tokio::ApiError::RequestError(e)
3291 if e.status().map(|status| status.as_u16()) == Some(404)
3292 )
3293}
3294
3295async fn fetch_repo_file(repo: &str, filename: &str, dest: &Path) -> Result<(), FileFetchError> {
3296 if crate::download::cache_file_usable(dest) {
3300 return Ok(());
3301 }
3302 refuse_fetch_when_never_download(&format!("{repo}/{filename}"))?;
3303
3304 let api = crate::hf_cache::api()
3305 .build()
3306 .map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
3307
3308 let already_cached = hf_hub::Cache::new(crate::hf_cache::hub_dir())
3311 .repo(hf_hub::Repo::model(repo.to_string()))
3312 .get(filename)
3313 .is_some();
3314 let repo_id = repo;
3315 let repo = api.model(repo.to_string());
3316 let path = repo.get(filename).await.map_err(|e| {
3317 let message = format!("{filename}: {e}");
3318 if hf_file_absent(&e) {
3319 FileFetchError::Absent(message)
3320 } else {
3321 FileFetchError::Failed(InferenceError::DownloadFailed(message))
3322 }
3323 })?;
3324 if !already_cached {
3325 crate::retire::note_fetched(repo_id, filename, crate::retire::snapshot_revision(&path));
3326 }
3327
3328 Ok(install_fetched_file(&path, dest)?)
3329}
3330
3331fn install_fetched_file(src: &Path, dest: &Path) -> Result<(), InferenceError> {
3333 if crate::download::cache_file_usable(dest) {
3334 return Ok(());
3335 }
3336
3337 match std::fs::symlink_metadata(dest) {
3338 Ok(_) => std::fs::remove_file(dest).map_err(|e| {
3339 InferenceError::DownloadFailed(format!(
3340 "remove unusable destination {}: {e}",
3341 dest.display()
3342 ))
3343 })?,
3344 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3345 Err(error) => {
3346 return Err(InferenceError::DownloadFailed(format!(
3347 "inspect destination {}: {error}",
3348 dest.display()
3349 )));
3350 }
3351 }
3352
3353 #[cfg(unix)]
3357 {
3358 if std::os::unix::fs::symlink(src, dest).is_ok() {
3359 return Ok(());
3360 }
3361 }
3362
3363 static INSTALL_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3364 let sequence = INSTALL_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3365 let file_name = dest
3366 .file_name()
3367 .and_then(|name| name.to_str())
3368 .unwrap_or("download");
3369 let temp = dest.with_file_name(format!(
3370 ".{file_name}.car-install-{}-{sequence}.tmp",
3371 std::process::id()
3372 ));
3373 let _ = std::fs::remove_file(&temp);
3374 std::fs::copy(src, &temp).map_err(|error| {
3375 InferenceError::DownloadFailed(format!(
3376 "copy to temporary destination {}: {error}",
3377 temp.display()
3378 ))
3379 })?;
3380 if let Err(error) = std::fs::rename(&temp, dest) {
3381 let _ = std::fs::remove_file(&temp);
3382 return Err(InferenceError::DownloadFailed(format!(
3383 "publish downloaded file at {}: {error}",
3384 dest.display()
3385 )));
3386 }
3387 Ok(())
3388}
3389
3390pub(crate) const FLUX_AUXILIARY: (&str, &str, &str, &str) = (
3395 "mlx-community/Flux-1.lite-8B-MLX-Q4",
3396 "Flux-1.lite-8B-MLX-Q4",
3397 "Freepik/flux.1-lite-8B",
3398 "tokenizer_2/tokenizer.json",
3399);
3400
3401pub(crate) const LTX_TEXT_ENCODER: &str = "mlx-community/gemma-3-12b-it-4bit";
3407
3408pub(crate) fn uses_ltx_text_encoder(schema: &ModelSchema) -> bool {
3411 matches!(schema.source, ModelSource::Mlx { .. })
3412 && schema.has_capability(ModelCapability::VideoGeneration)
3413}
3414
3415pub(crate) fn uses_flux_auxiliary(model_name: &str, hf_repo: &str) -> bool {
3416 hf_repo == FLUX_AUXILIARY.0 || model_name == FLUX_AUXILIARY.1
3417}
3418
3419fn auxiliary_mlx_files_missing(model_name: &str, hf_repo: &str, model_dir: &Path) -> bool {
3420 uses_flux_auxiliary(model_name, hf_repo)
3421 && !crate::download::cache_file_usable(
3422 &model_dir.join("tokenizer_2").join("tokenizer.json"),
3423 )
3424}
3425
3426fn chat_template_missing(model_dir: &Path) -> bool {
3439 let tok_cfg = model_dir.join("tokenizer_config.json");
3440 if !crate::download::cache_file_usable(&tok_cfg)
3441 || crate::download::cache_file_usable(&model_dir.join("chat_template.jinja"))
3442 || model_dir.join(CHAT_TEMPLATE_ABSENT).exists()
3443 {
3444 return false;
3445 }
3446 std::fs::read_to_string(&tok_cfg)
3447 .ok()
3448 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
3449 .is_some_and(|cfg| match cfg.get("chat_template") {
3450 None | Some(serde_json::Value::Null) => true,
3451 Some(serde_json::Value::String(template)) => template.trim().is_empty(),
3452 Some(serde_json::Value::Array(templates)) => templates.is_empty(),
3453 Some(_) => false,
3454 })
3455}
3456
3457const CHAT_TEMPLATE_ABSENT: &str = ".car-chat-template-absent";
3461
3462async fn ensure_auxiliary_mlx_files(
3463 model_name: &str,
3464 hf_repo: &str,
3465 model_dir: &Path,
3466) -> Result<(), InferenceError> {
3467 if chat_template_missing(model_dir) {
3474 match fetch_repo_file(
3475 hf_repo,
3476 "chat_template.jinja",
3477 &model_dir.join("chat_template.jinja"),
3478 )
3479 .await
3480 {
3481 Ok(()) => {}
3482 Err(FileFetchError::Absent(message)) => {
3483 tracing::debug!(repo = %hf_repo, %message, "no chat_template.jinja in this repo");
3484 if let Err(error) = std::fs::write(model_dir.join(CHAT_TEMPLATE_ABSENT), b"") {
3485 tracing::debug!(repo = %hf_repo, %error, "could not record the absent template");
3486 }
3487 }
3488 Err(FileFetchError::Failed(error @ InferenceError::NoEligibleModel { .. })) => {
3489 tracing::debug!(repo = %hf_repo, %error, "chat template not fetched: this request never downloads");
3490 }
3491 Err(FileFetchError::Failed(error)) => {
3492 return Err(InferenceError::DownloadFailed(format!(
3493 "{hf_repo} has no chat template installed and fetching chat_template.jinja failed, \
3494 so chat requests to it would fail: {error}"
3495 )));
3496 }
3497 }
3498 }
3499 if auxiliary_mlx_files_missing(model_name, hf_repo, model_dir) {
3500 let t5_tokenizer_path = model_dir.join("tokenizer_2").join("tokenizer.json");
3501 std::fs::create_dir_all(
3502 t5_tokenizer_path
3503 .parent()
3504 .ok_or_else(|| InferenceError::InferenceFailed("invalid tokenizer path".into()))?,
3505 )?;
3506 info!(
3507 path = %t5_tokenizer_path.display(),
3508 "downloading missing Flux tokenizer_2/tokenizer.json from base model"
3509 );
3510 download_file(FLUX_AUXILIARY.2, FLUX_AUXILIARY.3, &t5_tokenizer_path).await?;
3511 }
3512 Ok(())
3513}
3514
3515fn mlx_auxiliary_ready_without_download(model_name: &str, model_dir: &Path) -> bool {
3516 if model_name == "Flux-1.lite-8B-MLX-Q4" {
3517 return crate::download::cache_file_usable(
3518 &model_dir.join("tokenizer_2").join("tokenizer.json"),
3519 );
3520 }
3521 true
3522}
3523
3524fn physical_weights_ready(schema: &ModelSchema, models_dir: &Path) -> bool {
3527 physical_weights_ready_with_huggingface_hub(schema, models_dir, None)
3528}
3529
3530pub(crate) fn physical_weights_ready_with_huggingface_hub(
3531 schema: &ModelSchema,
3532 models_dir: &Path,
3533 huggingface_hub_root: Option<&Path>,
3534) -> bool {
3535 match &schema.source {
3536 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
3537 let managed_dir = models_dir.join(&schema.name);
3538 if mlx_snapshot_complete(schema, &managed_dir) {
3539 return true;
3540 }
3541 let shared_snapshot = match huggingface_hub_root {
3542 Some(root) => latest_huggingface_repo_snapshot_in(&crate::hf_cache::repo_dir_in(
3543 root, hf_repo,
3544 )),
3545 None => latest_huggingface_repo_snapshot(hf_repo),
3546 };
3547 shared_snapshot
3548 .as_deref()
3549 .is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3550 }
3551 ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
3552 ModelSource::Local { .. } => {
3553 crate::download::cache_file_usable(&models_dir.join(&schema.name).join("model.gguf"))
3554 }
3555 ModelSource::WindowsSpeech {}
3559 | ModelSource::AppleFoundationModels { .. }
3560 | ModelSource::VllmMlx { .. }
3561 | ModelSource::Ollama { .. }
3562 | ModelSource::RemoteApi { .. }
3563 | ModelSource::CodexCli { .. }
3564 | ModelSource::Proprietary { .. }
3565 | ModelSource::Delegated { .. } => true,
3566 }
3567}
3568
3569#[cfg(test)]
3570fn mlx_weights_ready_at(
3571 schema: &ModelSchema,
3572 managed_dir: &Path,
3573 shared_snapshot: Option<&Path>,
3574) -> bool {
3575 mlx_snapshot_complete(schema, managed_dir)
3576 || shared_snapshot.is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3577}
3578
3579fn mlx_snapshot_complete(schema: &ModelSchema, dir: &Path) -> bool {
3586 mlx_layout_complete(mlx_schema_is_diffusers(schema), dir)
3587 && mlx_auxiliary_ready_without_download(&schema.name, dir)
3588}
3589
3590fn mlx_schema_is_diffusers(schema: &ModelSchema) -> bool {
3594 schema.capabilities.iter().any(|capability| {
3595 matches!(
3596 capability,
3597 ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
3598 )
3599 })
3600}
3601
3602tokio::task_local! {
3603 static NEVER_DOWNLOAD: ();
3605}
3606
3607pub(crate) fn never_download_scope<F: std::future::Future>(
3625 never: bool,
3626 future: F,
3627) -> impl std::future::Future<Output = F::Output> {
3628 let future = Box::pin(future);
3629 async move {
3630 if never {
3631 NEVER_DOWNLOAD.scope((), future).await
3632 } else {
3633 future.await
3634 }
3635 }
3636}
3637
3638pub(crate) fn never_download_sync_scope<R>(never: bool, f: impl FnOnce() -> R) -> R {
3642 if never {
3643 NEVER_DOWNLOAD.sync_scope((), f)
3644 } else {
3645 f()
3646 }
3647}
3648
3649pub(crate) fn never_download_in_scope() -> bool {
3651 NEVER_DOWNLOAD.try_with(|_| ()).is_ok()
3652}
3653
3654#[cfg(test)]
3657pub(crate) static REFUSED_DOWNLOADS: std::sync::atomic::AtomicUsize =
3658 std::sync::atomic::AtomicUsize::new(0);
3659
3660pub(crate) fn not_installed_error(schema: &ModelSchema) -> InferenceError {
3663 InferenceError::NoEligibleModel {
3664 excluded_models: format!(
3665 "{name} is not installed, and this request never downloads a model. \
3666 Install it with `car models pull {name}`, or connect a provider's API key \
3667 so a model that is ready can be used",
3668 name = schema.name
3669 ),
3670 }
3671}
3672
3673fn refuse_download(schema: &ModelSchema) -> InferenceError {
3675 #[cfg(test)]
3676 REFUSED_DOWNLOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3677 tracing::info!(model = %schema.name, "refused a model download for a never-download request");
3678 not_installed_error(schema)
3679}
3680
3681pub(crate) fn refuse_fetch_when_never_download(what: &str) -> Result<(), InferenceError> {
3684 if !never_download_in_scope() {
3685 return Ok(());
3686 }
3687 #[cfg(test)]
3688 REFUSED_DOWNLOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3689 Err(InferenceError::NoEligibleModel {
3690 excluded_models: format!(
3691 "{what} would have to be downloaded, and this request never downloads a model"
3692 ),
3693 })
3694}
3695
3696fn drafter_dir(models_dir: &Path, schema: &ModelSchema) -> PathBuf {
3698 models_dir.join(&schema.name).join("mtp-drafter")
3699}
3700
3701fn mlx_layout_complete(is_diffusers: bool, dir: &Path) -> bool {
3710 (is_diffusers || crate::download::cache_file_usable(&dir.join("config.json")))
3711 && mlx_dir_has_weights(dir)
3712}
3713
3714#[allow(dead_code)]
3734fn mlx_row_needs_in_process_loader(schema: &ModelSchema) -> bool {
3735 use crate::schema::ModelCapability as C;
3736 let tagged = |t: &str| schema.tags.iter().any(|x| x == t);
3737 if tagged("speech") || tagged("requires-mlx-vlm") {
3738 return false;
3739 }
3740 if schema
3741 .capabilities
3742 .iter()
3743 .any(|c| matches!(c, C::ImageGeneration | C::VideoGeneration))
3744 {
3745 return false;
3746 }
3747 matches!(schema.source, ModelSource::Mlx { .. })
3748}
3749
3750pub(crate) fn mlx_dir_has_weights(dir: &Path) -> bool {
3772 let index = dir.join("model.safetensors.index.json");
3776 if index.is_file() {
3777 return sharded_weight_files(&index).is_some_and(|required| {
3778 !required.is_empty()
3779 && required
3780 .iter()
3781 .all(|shard| crate::download::cache_file_usable(&dir.join(shard)))
3782 });
3783 }
3784 mlx_dir_has_weights_depth(dir, 0)
3785}
3786
3787pub(crate) fn missing_weight_shards(dir: &Path) -> Vec<String> {
3799 let index = dir.join("model.safetensors.index.json");
3800 if !index.is_file() {
3801 return Vec::new();
3802 }
3803 let Some(required) = sharded_weight_files(&index) else {
3804 return Vec::new();
3807 };
3808 required
3809 .into_iter()
3810 .filter(|shard| !dir.join(shard).exists())
3811 .collect()
3812}
3813
3814fn sharded_weight_files(index: &Path) -> Option<Vec<String>> {
3821 let raw = std::fs::read_to_string(index).ok()?;
3822 let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
3823 let map = parsed.get("weight_map")?.as_object()?;
3824 let mut files: Vec<String> = map
3825 .values()
3826 .filter_map(|v| v.as_str().map(str::to_string))
3827 .collect();
3828 files.sort();
3829 files.dedup();
3830 Some(files)
3831}
3832
3833fn mlx_dir_has_weights_depth(dir: &Path, depth: usize) -> bool {
3844 if depth > 4 {
3845 return false;
3846 }
3847 let Ok(rd) = std::fs::read_dir(dir) else {
3848 return false;
3849 };
3850 rd.flatten().any(|e| {
3851 let p = e.path();
3852 let is_symlink = std::fs::symlink_metadata(&p)
3853 .map(|m| m.file_type().is_symlink())
3854 .unwrap_or(true);
3855 if p.is_dir() {
3856 !is_symlink && mlx_dir_has_weights_depth(&p, depth + 1)
3857 } else {
3858 p.extension().and_then(|x| x.to_str()) == Some("safetensors")
3862 && crate::download::cache_file_usable(&p)
3863 }
3864 })
3865}
3866
3867#[allow(dead_code)] fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
3869 latest_huggingface_repo_snapshot(repo_id).is_some()
3870}
3871
3872fn resolve_huggingface_ref_snapshot(repo_dir: &Path, name: &str) -> Option<PathBuf> {
3873 let sha = std::fs::read_to_string(repo_dir.join("refs").join(name))
3874 .ok()?
3875 .trim()
3876 .to_string();
3877 if sha.is_empty() {
3878 return None;
3879 }
3880
3881 let snapshot = repo_dir.join("snapshots").join(sha);
3882 if snapshot_looks_ready(&snapshot) {
3883 Some(snapshot)
3884 } else {
3885 None
3886 }
3887}
3888
3889pub(crate) fn latest_huggingface_repo_snapshot(repo_id: &str) -> Option<PathBuf> {
3890 let repo_dir = crate::hf_cache::repo_dir(repo_id);
3891 latest_huggingface_repo_snapshot_in(&repo_dir)
3892}
3893
3894pub(crate) fn latest_huggingface_repo_snapshot_in(repo_dir: &Path) -> Option<PathBuf> {
3895 if let Some(snapshot) = resolve_huggingface_ref_snapshot(repo_dir, "main") {
3896 return Some(snapshot);
3897 }
3898
3899 let snapshots = repo_dir.join("snapshots");
3900 let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(snapshots)
3901 .ok()?
3902 .filter_map(Result::ok)
3903 .map(|e| e.path())
3904 .filter(|p| p.is_dir() && snapshot_looks_ready(p))
3905 .map(|path| {
3906 let modified = path
3907 .metadata()
3908 .and_then(|metadata| metadata.modified())
3909 .unwrap_or(SystemTime::UNIX_EPOCH);
3910 (modified, path)
3911 })
3912 .collect();
3913 candidates.sort();
3914 candidates.pop().map(|(_, path)| path)
3915}
3916
3917fn snapshot_looks_ready(path: &Path) -> bool {
3918 if path.join("config.json").exists() || path.join("model_index.json").exists() {
3919 return true;
3920 }
3921 snapshot_contains_ext(path, "safetensors")
3922}
3923
3924fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
3925 let Ok(entries) = std::fs::read_dir(root) else {
3926 return false;
3927 };
3928 entries.filter_map(Result::ok).any(|entry| {
3929 let path = entry.path();
3930 if path.is_dir() {
3931 snapshot_contains_ext(&path, ext)
3932 } else {
3933 let ext_matches = path
3934 .extension()
3935 .and_then(|value| value.to_str())
3936 .map(|value| value.eq_ignore_ascii_case(ext))
3937 .unwrap_or(false);
3938 ext_matches && crate::download::cache_file_usable(&path)
3942 }
3943 })
3944}
3945
3946const BUILTIN_CATALOG_JSON: &str = include_str!("builtin_catalog.json");
3955
3956static BUILTIN_CATALOG: std::sync::LazyLock<Vec<ModelSchema>> = std::sync::LazyLock::new(|| {
3957 serde_json::from_str(BUILTIN_CATALOG_JSON)
3958 .expect("builtin_catalog.json failed to parse — fix the JSON, not this code")
3959});
3960
3961pub(crate) fn builtin_catalog() -> Vec<ModelSchema> {
3962 let mut catalog = BUILTIN_CATALOG.clone();
3963 catalog.extend(crate::openrouter::builtin_schemas());
3964 catalog
3965}
3966
3967pub const EXTRA_CREDENTIAL_ENV_NAMES: &[&str] = &[
3970 "OPENAI_API_KEY",
3971 "OPENROUTER_API_KEY",
3972 "ANTHROPIC_API_KEY",
3973 "ANTHROPIC_AUTH_TOKEN",
3974 "GEMINI_API_KEY",
3975 "GOOGLE_API_KEY",
3976 "ELEVENLABS_API_KEY",
3977 "GITHUB_TOKEN",
3978 "GH_TOKEN",
3979 "AWS_ACCESS_KEY_ID",
3980 "AWS_SECRET_ACCESS_KEY",
3981 "AWS_SESSION_TOKEN",
3982 "TAVILY_API_KEY",
3983 "HF_TOKEN",
3984 "CODEX_API_KEY",
3985 "PARSLEE_ORG_GRANTER_SEED",
3986 "PARSLEE_ORG_GRANTER_SEEDS",
3987];
3988
3989const CREDENTIAL_NAME_SUFFIXES: &[&str] = &[
3990 "_API_KEY",
3991 "_APIKEY",
3992 "_TOKEN",
3993 "_SECRET",
3994 "_PASSWORD",
3995 "_PASSWD",
3996 "_ACCESS_KEY",
3997 "_PRIVATE_KEY",
3998 "_CREDENTIALS",
3999];
4000
4001pub fn is_credential_env_name(name: &str) -> bool {
4007 let upper = name.to_ascii_uppercase();
4008 EXTRA_CREDENTIAL_ENV_NAMES.contains(&upper.as_str())
4009 || CREDENTIAL_NAME_SUFFIXES
4010 .iter()
4011 .any(|suffix| upper.ends_with(suffix))
4012 || builtin_credential_env_names().contains(name)
4013}
4014
4015pub fn builtin_credential_env_names() -> std::collections::BTreeSet<String> {
4021 let mut names = std::collections::BTreeSet::new();
4022 for model in builtin_catalog() {
4023 match model.source {
4024 ModelSource::RemoteApi {
4025 api_key_env,
4026 api_key_envs,
4027 ..
4028 } => {
4029 names.insert(api_key_env);
4030 names.extend(api_key_envs);
4031 }
4032 ModelSource::Proprietary { auth, .. } => match auth {
4033 ProprietaryAuth::ApiKeyEnv { env_var }
4034 | ProprietaryAuth::BearerTokenEnv { env_var } => {
4035 names.insert(env_var);
4036 }
4037 ProprietaryAuth::OAuth2Pkce { .. } | ProprietaryAuth::ChatGptSubscription {} => {}
4038 },
4039 ModelSource::Local { .. }
4040 | ModelSource::CodexCli { .. }
4041 | ModelSource::Ollama { .. }
4042 | ModelSource::Mlx { .. }
4043 | ModelSource::WhisperCpp { .. }
4044 | ModelSource::WindowsSpeech {}
4045 | ModelSource::VllmMlx { .. }
4046 | ModelSource::ManagedVllmMlx { .. }
4047 | ModelSource::AppleFoundationModels { .. }
4048 | ModelSource::Delegated { .. } => {}
4049 }
4050 }
4051 names
4052}
4053
4054pub fn is_builtin_subscription_billed(model_id: &str) -> bool {
4062 let latest_id = implicit_latest_id(model_id);
4063 builtin_catalog().into_iter().any(|model| {
4064 (model.id == model_id || latest_id.as_deref() == Some(model.id.as_str()))
4065 && model.is_subscription_billed()
4066 })
4067}
4068
4069#[doc(hidden)]
4072pub fn builtin_catalog_with_huggingface_hub_for_testing(
4073 models_dir: &Path,
4074 huggingface_hub_root: &Path,
4075) -> Vec<ModelSchema> {
4076 let mut catalog = builtin_catalog();
4077 for schema in &mut catalog {
4078 schema.weights_ready = physical_weights_ready_with_huggingface_hub(
4079 schema,
4080 models_dir,
4081 Some(huggingface_hub_root),
4082 );
4083 }
4084 catalog
4085}
4086
4087pub fn implicit_latest_id(model: &str) -> Option<String> {
4091 (!model.contains(':')).then(|| format!("{model}:latest"))
4092}
4093
4094pub fn find_model_row<'a>(rows: &'a [ModelSchema], model: &str) -> Option<&'a ModelSchema> {
4101 rows.iter()
4102 .find(|row| row.id == model)
4103 .or_else(|| find_named(model, rows.iter(), |row| &row.name))
4104 .or_else(|| {
4105 let latest = implicit_latest_id(model)?;
4106 rows.iter().find(|row| row.id == latest)
4107 })
4108}
4109
4110pub(crate) fn find_named<T>(
4116 name: &str,
4117 mut items: impl Iterator<Item = T> + Clone,
4118 name_of: impl Fn(&T) -> &str,
4119) -> Option<T> {
4120 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4121 if !name.to_ascii_lowercase().ends_with("-mlx") {
4122 let mlx = format!("{name}-MLX");
4123 if let Some(mlx_variant) = items
4124 .clone()
4125 .find(|item| name_of(item).eq_ignore_ascii_case(&mlx))
4126 {
4127 return Some(mlx_variant);
4128 }
4129 }
4130
4131 items.find(|item| name_of(item).eq_ignore_ascii_case(name))
4132}
4133
4134#[cfg(test)]
4135mod tests {
4136 use crate::openrouter::StateRootScope;
4137
4138 #[test]
4139 fn chat_template_missing_only_when_no_template_anywhere() {
4140 let dir = tempfile::tempdir().unwrap();
4141 let cfg = dir.path().join("tokenizer_config.json");
4142 assert!(!chat_template_missing(dir.path()));
4144 std::fs::write(&cfg, r#"{"eos_token":"<|endoftext|>"}"#).unwrap();
4145 assert!(chat_template_missing(dir.path()));
4146 std::fs::write(&cfg, r#"{"chat_template":"{{ messages }}"}"#).unwrap();
4147 assert!(!chat_template_missing(dir.path()));
4148 std::fs::write(&cfg, r#"{"eos_token":"<|endoftext|>"}"#).unwrap();
4149 std::fs::write(dir.path().join("chat_template.jinja"), "{{ messages }}").unwrap();
4150 assert!(!chat_template_missing(dir.path()));
4151 }
4152
4153 #[test]
4156 fn an_empty_or_null_chat_template_is_missing() {
4157 let dir = tempfile::tempdir().unwrap();
4158 let cfg = dir.path().join("tokenizer_config.json");
4159 for empty in ["null", "\"\"", "\" \"", "[]"] {
4160 std::fs::write(&cfg, format!(r#"{{"chat_template":{empty}}}"#)).unwrap();
4161 assert!(chat_template_missing(dir.path()), "chat_template {empty}");
4162 }
4163 std::fs::write(
4164 &cfg,
4165 r#"{"chat_template":[{"name":"default","template":"{{ messages }}"}]}"#,
4166 )
4167 .unwrap();
4168 assert!(!chat_template_missing(dir.path()));
4169 std::fs::write(&cfg, r#"{"chat_template":null}"#).unwrap();
4171 std::fs::write(dir.path().join(CHAT_TEMPLATE_ABSENT), b"").unwrap();
4172 assert!(!chat_template_missing(dir.path()));
4173 }
4174
4175 #[test]
4183 fn a_sharded_model_missing_one_shard_is_not_installed() {
4184 let tmp = tempfile::tempdir().unwrap();
4185 let dir = tmp.path();
4186 std::fs::write(
4187 dir.join("model.safetensors.index.json"),
4188 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
4189 "b":"model-00002-of-00002.safetensors"}}"#,
4190 )
4191 .unwrap();
4192 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
4194 assert!(
4195 !mlx_dir_has_weights(dir),
4196 "a missing shard must read as not-installed, or pull silently no-ops"
4197 );
4198
4199 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
4201 assert!(
4202 mlx_dir_has_weights(dir),
4203 "a complete shard set must read as installed"
4204 );
4205 }
4206
4207 #[test]
4215 fn missing_shards_are_reported_by_name() {
4216 let tmp = tempfile::tempdir().unwrap();
4217 let dir = tmp.path();
4218 std::fs::write(
4219 dir.join("model.safetensors.index.json"),
4220 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
4221 "b":"model-00002-of-00002.safetensors"}}"#,
4222 )
4223 .unwrap();
4224 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
4225
4226 assert_eq!(
4227 missing_weight_shards(dir),
4228 vec!["model-00001-of-00002.safetensors".to_string()],
4229 "the absent shard must be named, not just counted"
4230 );
4231
4232 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
4233 assert!(
4234 missing_weight_shards(dir).is_empty(),
4235 "a complete shard set must report nothing missing"
4236 );
4237 }
4238
4239 #[test]
4243 fn missing_shards_is_empty_without_an_index() {
4244 let tmp = tempfile::tempdir().unwrap();
4245 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
4246 assert!(missing_weight_shards(tmp.path()).is_empty());
4247
4248 let bad = tempfile::tempdir().unwrap();
4251 std::fs::write(bad.path().join("model.safetensors.index.json"), b"not json").unwrap();
4252 assert!(missing_weight_shards(bad.path()).is_empty());
4253 }
4254
4255 #[test]
4257 fn a_single_file_model_still_counts_without_an_index() {
4258 let tmp = tempfile::tempdir().unwrap();
4259 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
4260 assert!(mlx_dir_has_weights(tmp.path()));
4261 }
4262
4263 #[test]
4266 fn an_unparseable_index_fails_closed() {
4267 let tmp = tempfile::tempdir().unwrap();
4268 std::fs::write(
4269 tmp.path().join("model.safetensors.index.json"),
4270 b"{not-json",
4271 )
4272 .unwrap();
4273 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
4274 assert!(
4275 !mlx_dir_has_weights(tmp.path()),
4276 "an unreadable index must not fall back to a stray weight"
4277 );
4278 }
4279
4280 use super::*;
4281 use tempfile::TempDir;
4282
4283 #[test]
4284 fn mlx_dir_has_weights_detects_completeness() {
4285 let tmp = TempDir::new().unwrap();
4286 let dir = tmp.path();
4287
4288 std::fs::write(dir.join("config.json"), "{}").unwrap();
4290 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
4291 assert!(
4292 !mlx_dir_has_weights(dir),
4293 "config-only stub must not count as installed"
4294 );
4295
4296 std::fs::write(dir.join("model.safetensors.index.json"), "{}").unwrap();
4298 assert!(!mlx_dir_has_weights(dir), "index.json alone is not weights");
4299
4300 std::fs::remove_file(dir.join("model.safetensors.index.json")).unwrap();
4302 std::fs::write(dir.join("model.safetensors"), b"\x00\x01\x02").unwrap();
4303 assert!(mlx_dir_has_weights(dir));
4304 }
4305
4306 #[test]
4307 fn mlx_dir_has_weights_handles_sharded_and_dangling_symlinks() {
4308 let sharded = TempDir::new().unwrap();
4309 std::fs::write(sharded.path().join("config.json"), "{}").unwrap();
4310 std::fs::write(
4311 sharded.path().join("model-00001-of-00002.safetensors"),
4312 b"\x00",
4313 )
4314 .unwrap();
4315 assert!(mlx_dir_has_weights(sharded.path()), "sharded shard counts");
4316
4317 #[cfg(unix)]
4320 {
4321 let dangling = TempDir::new().unwrap();
4322 std::fs::write(dangling.path().join("config.json"), "{}").unwrap();
4323 std::os::unix::fs::symlink(
4324 dangling.path().join("does-not-exist"),
4325 dangling.path().join("model.safetensors"),
4326 )
4327 .unwrap();
4328 assert!(
4329 !mlx_dir_has_weights(dangling.path()),
4330 "dangling weight symlink must count as absent"
4331 );
4332 }
4333 }
4334
4335 struct TestRegistry {
4357 registry: UnifiedRegistry,
4358 _tmp: TempDir,
4362 _environment: tokio::sync::MutexGuard<'static, ()>,
4363 }
4364
4365 impl std::ops::Deref for TestRegistry {
4366 type Target = UnifiedRegistry;
4367
4368 fn deref(&self) -> &Self::Target {
4369 &self.registry
4370 }
4371 }
4372
4373 impl std::ops::DerefMut for TestRegistry {
4374 fn deref_mut(&mut self) -> &mut Self::Target {
4375 &mut self.registry
4376 }
4377 }
4378
4379 fn test_registry() -> TestRegistry {
4380 let _environment = crate::openrouter::test_environment_scope();
4381 let tmp = TempDir::new().unwrap();
4382 let registry = UnifiedRegistry::new_with_state_root(
4383 tmp.path().to_path_buf(),
4384 tmp.path().join("models"),
4385 );
4386 TestRegistry {
4387 registry,
4388 _tmp: tmp,
4389 _environment,
4390 }
4391 }
4392
4393 #[test]
4398 fn a_rows_unknown_credential_state_follows_the_credential_it_needs() {
4399 let row = |source: serde_json::Value, provider: &str| -> ModelSchema {
4400 serde_json::from_value(serde_json::json!({
4401 "id": format!("{provider}/row"), "name": "row", "provider": provider,
4402 "family": "test", "capabilities": ["generate"], "context_length": 1000,
4403 "source": source,
4404 }))
4405 .unwrap()
4406 };
4407 let keyed = row(
4408 serde_json::json!({"type": "remote_api", "endpoint": "https://example.invalid",
4409 "api_key_env": "VENDOR_API_KEY", "api_key_envs": [], "protocol": "anthropic"}),
4410 "vendor",
4411 );
4412 let codex = row(
4413 serde_json::json!({"type": "proprietary", "provider": "openai-codex",
4414 "endpoint": "https://example.invalid", "auth": {"type": "chatgpt_subscription"},
4415 "protocol": {}}),
4416 "openai-codex",
4417 );
4418 let openrouter = row(
4419 serde_json::json!({"type": "remote_api", "endpoint": "https://example.invalid",
4420 "api_key_env": "OPENROUTER_API_KEY", "api_key_envs": [], "protocol": "open_router"}),
4421 "openrouter",
4422 );
4423 let parslee = row(
4424 serde_json::json!({"type": "proprietary", "provider": "parslee",
4425 "endpoint": "https://api.parslee.ai", "auth": {"type": "o_auth2_pkce",
4426 "authority": "https://api.parslee.ai", "client_id": "parslee-car", "scopes": []},
4427 "protocol": {}}),
4428 "parslee",
4429 );
4430
4431 let mut registry = UnifiedRegistry::new_empty(std::path::PathBuf::from("/nonexistent"));
4432 for schema in [&keyed, &codex, &openrouter, &parslee] {
4433 assert!(
4434 !registry.row_credential_state_unknown(schema),
4435 "{}",
4436 schema.id
4437 );
4438 }
4439 registry
4440 .credential_state_unknown
4441 .insert("VENDOR_API_KEY".into());
4442 registry.codex_state_unknown = true;
4443 registry.openrouter_state_unknown = true;
4444 assert!(registry.row_credential_state_unknown(&keyed));
4445 assert!(registry.row_credential_state_unknown(&codex));
4446 assert!(registry.row_credential_state_unknown(&openrouter));
4447 assert!(!registry.row_credential_state_unknown(&parslee));
4448 }
4449
4450 #[test]
4464 fn an_unreadable_credential_is_recorded_as_unknown_not_absent() {
4465 let registry = UnifiedRegistry::new_empty(std::path::PathBuf::from("/nonexistent"));
4469 assert!(
4470 !registry.credential_state_is_unknown("VENDOR_API_KEY"),
4471 "an unprobed registry must not claim a credential state is unknown"
4472 );
4473
4474 assert_eq!(
4478 credential_presence("CAR_TEST_CREDENTIAL_THAT_DOES_NOT_EXIST"),
4479 CredentialPresence::Absent
4480 );
4481 }
4482
4483 #[test]
4484 fn the_withheld_set_covers_keys_and_leaves_settings() {
4485 for name in builtin_credential_env_names() {
4486 assert!(is_credential_env_name(&name), "{name}");
4487 }
4488 for name in EXTRA_CREDENTIAL_ENV_NAMES.iter().chain(&[
4489 "MY_VENDOR_API_KEY",
4490 "SOME_SERVICE_TOKEN",
4491 "DB_PASSWORD",
4492 "X_SECRET",
4493 "CAR_AUTH_TOKEN",
4494 ]) {
4495 assert!(is_credential_env_name(name), "{name}");
4496 }
4497 for name in [
4498 "CAR_NO_AUTH",
4499 "VLLM_MLX_ENDPOINT",
4500 "RUST_LOG",
4501 "CAR_MCP_BIND",
4502 "PARSLEE_SYNC_ORG_SCOPE",
4503 "TOKHN_STT_PROVIDER",
4504 "PARSLEE_API_BASE",
4505 ] {
4506 assert!(!is_credential_env_name(name), "{name}");
4507 }
4508 }
4509
4510 #[tokio::test(flavor = "current_thread")]
4514 async fn a_key_kept_only_in_the_store_reads_as_present_and_one_in_a_hand_made_env_file_does_not(
4515 ) {
4516 let _env = crate::openrouter::test_environment_scope_async().await;
4517 const NAME: &str = "CAR_PRESENCE_TEST_API_KEY";
4518 let home = tempfile::tempdir().unwrap();
4519 let store = home.path().join("store");
4520 std::fs::create_dir_all(&store).unwrap();
4521 std::fs::write(home.path().join("env"), format!("{NAME}=fake-from-file\n")).unwrap();
4522 let previous = (
4523 std::env::var_os(car_home::ENV_VAR),
4524 std::env::var_os("CAR_SECRETS_FILE_DIR"),
4525 std::env::var_os(NAME),
4526 );
4527 std::env::set_var(car_home::ENV_VAR, home.path());
4528 std::env::set_var("CAR_SECRETS_FILE_DIR", &store);
4529 std::env::remove_var(NAME);
4530
4531 let file_only = credential_presence(NAME);
4532 let secrets = car_secrets::ActivityScope::begin();
4533 car_secrets::SecretStore::new()
4534 .put(
4535 &car_secrets::SecretRef::with_default_service(NAME),
4536 "fake-from-store",
4537 )
4538 .unwrap();
4539 let before = secrets.activity();
4540 let store_only = credential_presence(NAME);
4541 let reads = secrets.activity().get_attempts - before.get_attempts;
4542
4543 match previous.0 {
4544 Some(value) => std::env::set_var(car_home::ENV_VAR, value),
4545 None => std::env::remove_var(car_home::ENV_VAR),
4546 }
4547 match previous.1 {
4548 Some(value) => std::env::set_var("CAR_SECRETS_FILE_DIR", value),
4549 None => std::env::remove_var("CAR_SECRETS_FILE_DIR"),
4550 }
4551 if let Some(value) = previous.2 {
4552 std::env::set_var(NAME, value);
4553 }
4554 assert_eq!(file_only, CredentialPresence::Absent);
4555 assert_eq!(store_only, CredentialPresence::Present);
4556 assert_eq!(reads, 0, "presence must not decrypt the value");
4557 }
4558
4559 #[test]
4560 fn credential_required_names_a_key_only_when_a_key_is_the_answer() {
4561 let remote = |available: bool, deprecated: bool| {
4562 let mut m = test_generate_schema(
4563 "vendor/m",
4564 "m",
4565 ModelSource::RemoteApi {
4566 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4567 endpoint: "https://example.invalid/v1".into(),
4568 api_key_env: "VENDOR_API_KEY".into(),
4569 api_key_envs: vec![],
4570 api_version: None,
4571 },
4572 );
4573 m.provider = "vendor".into();
4574 m.available = available;
4575 m.deprecated = deprecated;
4576 m
4577 };
4578
4579 assert_eq!(
4580 remote(false, false).credential_required().as_deref(),
4581 Some("VENDOR_API_KEY"),
4582 "an unavailable plain remote row is exactly the case this field is for"
4583 );
4584
4585 assert_eq!(
4586 remote(true, false).credential_required(),
4587 None,
4588 "an available row needs nothing"
4589 );
4590 assert_eq!(
4591 remote(false, true).credential_required(),
4592 None,
4593 "a deprecated row does not become usable by adding a key"
4594 );
4595
4596 let mut multi = remote(false, false);
4600 if let ModelSource::RemoteApi {
4601 ref mut api_key_envs,
4602 ..
4603 } = multi.source
4604 {
4605 *api_key_envs = vec!["VENDOR_A".into(), "VENDOR_B".into()];
4606 }
4607 assert_eq!(multi.credential_required(), None);
4608
4609 let mut router = remote(false, false);
4612 router.source = ModelSource::RemoteApi {
4613 protocol: crate::schema::ApiProtocol::OpenRouter,
4614 endpoint: "https://openrouter.ai/api/v1".into(),
4615 api_key_env: "OPENROUTER_API_KEY".into(),
4616 api_key_envs: vec![],
4617 api_version: None,
4618 };
4619 assert_eq!(router.credential_required(), None);
4620
4621 let mut local = test_generate_schema(
4623 "mlx/local",
4624 "local",
4625 ModelSource::Mlx {
4626 hf_repo: "org/repo".into(),
4627 hf_weight_file: None,
4628 },
4629 );
4630 local.available = false;
4631 assert_eq!(local.credential_required(), None);
4632 }
4633
4634 fn test_generate_schema(id: &str, name: &str, source: ModelSource) -> ModelSchema {
4635 ModelSchema {
4636 id: id.into(),
4637 name: name.into(),
4638 provider: "local".into(),
4639 family: "qwen3".into(),
4640 version: "test".into(),
4641 capabilities: vec![ModelCapability::Generate],
4642 context_length: 4096,
4643 max_output_tokens: None,
4644 param_count: String::new(),
4645 quantization: None,
4646 performance: PerformanceEnvelope::default(),
4647 cost: CostModel::default(),
4648 source,
4649 tags: vec![],
4650 supported_params: vec![],
4651 public_benchmarks: vec![],
4652 trust_tier: crate::schema::TrustTier::Curated,
4653 deprecated: false,
4654 available: false,
4655 weights_ready: false,
4656 }
4657 }
4658
4659 #[test]
4664 fn model_info_carries_weights_ready_through_the_projection() {
4665 let mut schema = test_generate_schema(
4666 "mlx-community/car894-test-4bit",
4667 "car894-test-4bit",
4668 ModelSource::Mlx {
4669 hf_repo: "mlx-community/car894-test-4bit".into(),
4670 hf_weight_file: None,
4671 },
4672 );
4673
4674 schema.weights_ready = false;
4675 assert!(
4676 !ModelInfo::from(&schema).weights_ready,
4677 "a schema with no weights on disk must project weights_ready = false"
4678 );
4679
4680 schema.weights_ready = true;
4681 assert!(
4682 ModelInfo::from(&schema).weights_ready,
4683 "a schema with weights on disk must project weights_ready = true"
4684 );
4685 }
4686
4687 #[test]
4693 fn model_info_carries_downloads_weights_through_the_projection() {
4694 let mlx = test_generate_schema(
4695 "mlx-community/car894-test-4bit",
4696 "car894-test-4bit",
4697 ModelSource::Mlx {
4698 hf_repo: "mlx-community/car894-test-4bit".into(),
4699 hf_weight_file: None,
4700 },
4701 );
4702 assert!(
4703 ModelInfo::from(&mlx).downloads_weights,
4704 "an MLX entry downloads weights"
4705 );
4706
4707 for (label, source) in [
4710 ("windows speech", ModelSource::WindowsSpeech {}),
4711 (
4712 "apple foundation",
4713 ModelSource::AppleFoundationModels { use_case: None },
4714 ),
4715 ] {
4716 let schema = test_generate_schema("car894/os-model", "os-model", source);
4717 let info = ModelInfo::from(&schema);
4718 assert!(
4719 !info.downloads_weights,
4720 "{label} installs nothing, so the projection must say so"
4721 );
4722 assert!(
4723 info.is_local,
4724 "{label} is still local — which is exactly why is_local cannot stand in"
4725 );
4726 }
4727
4728 let external = test_generate_schema(
4733 "car894/external-model",
4734 "external-model",
4735 ModelSource::VllmMlx {
4736 endpoint: "http://localhost:8000".into(),
4737 model_name: "mlx-community/car894-test-4bit".into(),
4738 },
4739 );
4740 assert!(!external.is_local());
4741 assert!(external.is_remote());
4742 assert!(!external.requires_apple_silicon());
4743 let info = ModelInfo::from(&external);
4744 assert!(!info.is_local);
4745 assert!(
4746 !info.downloads_weights,
4747 "external vllm-mlx owns its weights, so CAR installs nothing"
4748 );
4749 }
4750
4751 #[test]
4752 fn model_info_classifies_only_raw_vllm_mlx_as_operator_managed_external() {
4753 for endpoint in ["http://localhost:8000", "https://models.example.invalid/v1"] {
4754 let schema = test_generate_schema(
4755 "external/model",
4756 "external-model",
4757 ModelSource::VllmMlx {
4758 endpoint: endpoint.into(),
4759 model_name: "mlx-community/external-model".into(),
4760 },
4761 );
4762 let info = ModelInfo::from(&schema);
4763 assert!(info.operator_managed_external_runtime);
4764 assert_eq!(
4765 serde_json::to_value(info).unwrap()["operator_managed_external_runtime"],
4766 true
4767 );
4768 }
4769
4770 for source in [
4771 ModelSource::RemoteApi {
4772 endpoint: "https://cloud.example.invalid/v1".into(),
4773 api_key_env: "CAR_TEST_KEY".into(),
4774 api_key_envs: vec![],
4775 api_version: None,
4776 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4777 },
4778 ModelSource::ManagedVllmMlx {
4779 hf_repo: "mlx-community/car-owned-model".into(),
4780 hf_weight_file: None,
4781 draft_hf_repo: None,
4782 },
4783 ] {
4784 assert!(
4785 !ModelInfo::from(&test_generate_schema(
4786 "not-external/model",
4787 "not-external-model",
4788 source,
4789 ))
4790 .operator_managed_external_runtime
4791 );
4792 }
4793 }
4794
4795 #[test]
4802 fn fresh_machine_mlx_entry_is_available_but_not_weights_ready() {
4803 let mut reg = test_registry();
4804 let id = "mlx-community/car894-fresh-4bit";
4805 reg.register(test_generate_schema(
4806 id,
4807 "car894-fresh-4bit",
4808 ModelSource::Mlx {
4809 hf_repo: "mlx-community/car894-fresh-4bit".into(),
4810 hf_weight_file: None,
4811 },
4812 ));
4813
4814 let registered = reg
4815 .get(id)
4816 .expect("the model just registered must be in the registry");
4817 let info = ModelInfo::from(registered);
4818
4819 assert!(
4821 !registered.weights_ready,
4822 "an empty models dir means no weights on disk"
4823 );
4824 assert!(
4825 !info.weights_ready,
4826 "the CLI-facing projection must report the same: nothing installed"
4827 );
4828
4829 #[cfg(car_mlxlm_swift_built)]
4830 {
4831 assert!(
4834 registered.available,
4835 "a declared hf_repo makes an MLX entry runnable before download (#164)"
4836 );
4837 assert!(
4838 info.available,
4839 "the projection must keep reporting it as runnable"
4840 );
4841 }
4842 #[cfg(not(car_mlxlm_swift_built))]
4843 {
4844 assert!(
4847 !registered.available,
4848 "the Swift MLX backend is absent, so it must not be runnable"
4849 );
4850 assert!(!info.available);
4851 }
4852 }
4853
4854 fn write_complete_mlx_snapshot(dir: &Path) {
4855 std::fs::create_dir_all(dir).unwrap();
4856 std::fs::write(dir.join("config.json"), b"{}").unwrap();
4857 std::fs::write(dir.join("tokenizer.json"), b"{}").unwrap();
4858 std::fs::write(
4859 dir.join("model.safetensors.index.json"),
4860 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
4861 )
4862 .unwrap();
4863 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"one").unwrap();
4864 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
4865 }
4866
4867 #[test]
4868 fn complete_managed_and_shared_mlx_snapshots_are_physically_ready() {
4869 let schema = test_generate_schema(
4870 "mlx/qwen3-4b:4bit",
4871 "Qwen3-4B-MLX",
4872 ModelSource::Mlx {
4873 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
4874 hf_weight_file: None,
4875 },
4876 );
4877 let root = tempfile::tempdir().unwrap();
4878 let managed = root.path().join("managed");
4879 let shared = root.path().join("shared");
4880
4881 write_complete_mlx_snapshot(&managed);
4882 assert!(mlx_weights_ready_at(&schema, &managed, None));
4883
4884 std::fs::remove_dir_all(&managed).unwrap();
4885 write_complete_mlx_snapshot(&shared);
4886 assert!(mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4887 }
4888
4889 #[test]
4890 fn zero_byte_gguf_is_not_physically_ready() {
4891 let schema = test_generate_schema(
4892 "qwen/qwen3-4b:q4_k_m",
4893 "Qwen3-4B",
4894 ModelSource::Local {
4895 hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
4896 hf_filename: "model.gguf".into(),
4897 tokenizer_repo: "Qwen/Qwen3-4B".into(),
4898 },
4899 );
4900 let root = tempfile::tempdir().unwrap();
4901 let model_dir = root.path().join(&schema.name);
4902 std::fs::create_dir_all(&model_dir).unwrap();
4903 std::fs::write(model_dir.join("model.gguf"), b"").unwrap();
4904
4905 assert!(!physical_weights_ready(&schema, root.path()));
4906 std::fs::write(model_dir.join("model.gguf"), b"gguf").unwrap();
4907 assert!(physical_weights_ready(&schema, root.path()));
4908 }
4909
4910 #[test]
4911 fn shared_mlx_snapshot_missing_an_indexed_shard_is_not_physically_ready() {
4912 let schema = test_generate_schema(
4913 "mlx/qwen3-8b:4bit",
4914 "Qwen3-8B-MLX",
4915 ModelSource::Mlx {
4916 hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
4917 hf_weight_file: None,
4918 },
4919 );
4920 let root = tempfile::tempdir().unwrap();
4921 let managed = root.path().join("managed");
4922 let shared = root.path().join("shared");
4923 write_complete_mlx_snapshot(&shared);
4924 std::fs::remove_file(shared.join("model-00001-of-00002.safetensors")).unwrap();
4925
4926 assert!(!mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4927 }
4928
4929 #[test]
4961 fn a_gateway_that_reports_no_upstream_stops_being_advertised() {
4962 let _guard = crate::openrouter::test_environment_scope();
4963 let _home = StateRootScope::new();
4966 crate::openrouter::clear_gateway_unconfigured();
4967
4968 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
4969 .into_iter()
4970 .filter(|s| crate::openrouter::is_curated_managed_gateway_alias(&s.id))
4971 .collect();
4972 assert!(
4973 !managed.is_empty(),
4974 "precondition: the curated catalog must still carry managed aliases"
4975 );
4976
4977 let availability_of = |schema: &ModelSchema| match &schema.source {
4978 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
4979 &schema.id,
4980 &schema.provider,
4981 provider,
4982 auth,
4983 true,
4985 &std::collections::HashMap::new(),
4986 ),
4987 other => panic!("managed aliases must be Proprietary, got {other:?}"),
4988 };
4989
4990 assert!(
4991 managed.iter().all(availability_of),
4992 "precondition: an authenticated session advertises these today"
4993 );
4994
4995 crate::openrouter::note_gateway_unconfigured();
4996 assert!(
4997 managed.iter().all(|s| !availability_of(s)),
4998 "after the gateway says it has no OpenRouter upstream, every alias in \
4999 the namespace must report unavailable — that claim is what cost the \
5000 benchmark sweep in #786"
5001 );
5002
5003 crate::openrouter::clear_gateway_unconfigured();
5007 assert!(
5008 managed.iter().all(availability_of),
5009 "the suppression must be recoverable, not a one-way latch"
5010 );
5011 }
5012
5013 #[test]
5027 fn constructing_a_registry_with_a_live_session_leaves_the_gateway_observation_alone() {
5028 let _guard = crate::openrouter::test_environment_scope();
5029 let home = StateRootScope::new();
5030
5031 crate::openrouter::note_gateway_unconfigured();
5032 let _registry = UnifiedRegistry::new_with_session(
5033 home.path().to_path_buf(),
5034 home.path().join("models"),
5035 None,
5036 SessionProbe::Fixed(true),
5037 );
5038
5039 let observed = crate::openrouter::gateway_unconfigured();
5040 let persisted = crate::openrouter::gateway_state_path().exists();
5041
5042 crate::openrouter::clear_gateway_unconfigured();
5043
5044 assert!(
5045 observed,
5046 "a signed-in session has no reason to forget what the gateway said"
5047 );
5048 assert!(
5049 persisted,
5050 "the durable half of the observation must survive construction too"
5051 );
5052 }
5053
5054 #[test]
5062 fn constructing_a_registry_with_no_session_still_forgets_the_gateway_observation() {
5063 let _guard = crate::openrouter::test_environment_scope();
5064 let home = StateRootScope::new();
5065
5066 crate::openrouter::note_gateway_unconfigured();
5067 let recorded = crate::openrouter::gateway_unconfigured();
5068 let _registry = UnifiedRegistry::new_with_session(
5069 home.path().to_path_buf(),
5070 home.path().join("models"),
5071 None,
5072 SessionProbe::Fixed(false),
5073 );
5074
5075 let observed = crate::openrouter::gateway_unconfigured();
5076 let persisted = crate::openrouter::gateway_state_path().exists();
5077
5078 crate::openrouter::clear_gateway_unconfigured();
5079
5080 assert!(
5081 recorded,
5082 "precondition: the observation is on record before construction"
5083 );
5084 assert!(
5085 !observed,
5086 "sign-out must still discard the session-scoped verdict (#786)"
5087 );
5088 assert!(
5089 !persisted,
5090 "and the durable copy with it — otherwise the next sign-in inherits it from disk"
5091 );
5092 }
5093
5094 #[test]
5111 fn an_ordinary_test_registry_does_not_disturb_a_separately_set_observation() {
5112 let _guard = crate::openrouter::test_environment_scope();
5113 let home = StateRootScope::new();
5114
5115 crate::openrouter::note_gateway_unconfigured();
5116 let _registry = UnifiedRegistry::new_with_state_root(
5117 home.path().to_path_buf(),
5118 home.path().join("models"),
5119 );
5120
5121 let observed = crate::openrouter::gateway_unconfigured();
5122
5123 crate::openrouter::clear_gateway_unconfigured();
5124
5125 assert!(
5126 observed,
5127 "constructing a registry is not a statement about the session, so it \
5128 must not erase an observation another test just recorded (#986)"
5129 );
5130 assert!(
5131 !SessionProbe::Inert.may_forget_session_evidence(),
5132 "the `cfg(test)` construction default must be a probe that answers \
5133 the session question without acting on it — this is the half of \
5134 the guarantee that does not depend on whether the runner happens \
5135 to be signed in"
5136 );
5137 }
5138
5139 #[test]
5152 fn a_rejected_credential_stops_the_managed_lane_being_advertised() {
5153 let _guard = crate::openrouter::test_environment_scope();
5154 let _home = StateRootScope::new();
5155 crate::parslee_credential::clear_credential_rejected();
5156
5157 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
5158 .into_iter()
5159 .filter(|s| s.provider == "parslee")
5160 .collect();
5161 assert!(
5162 !managed.is_empty(),
5163 "precondition: the curated catalog must still carry parslee rows"
5164 );
5165
5166 let availability_of = |schema: &ModelSchema| match &schema.source {
5167 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
5168 &schema.id,
5169 &schema.provider,
5170 provider,
5171 auth,
5172 true,
5174 &std::collections::HashMap::new(),
5175 ),
5176 other => panic!("parslee rows must be Proprietary, got {other:?}"),
5177 };
5178
5179 assert!(
5180 managed.iter().all(availability_of),
5181 "precondition: an authenticated session advertises these today"
5182 );
5183
5184 crate::parslee_credential::note_credential_rejected();
5185 assert!(
5186 managed.iter().all(|s| !availability_of(s)),
5187 "after the server rejects the credential, EVERY parslee row must \
5188 report unavailable — unlike the gateway verdict this is not scoped \
5189 to the curated OpenRouter aliases, because a dead credential kills \
5190 the whole namespace"
5191 );
5192
5193 crate::parslee_credential::clear_credential_rejected();
5196 assert!(
5197 managed.iter().all(availability_of),
5198 "the suppression must lift once the credential works again"
5199 );
5200 }
5201
5202 #[test]
5219 fn an_explicit_status_refresh_sees_the_same_environment_credential() {
5220 let _environment = crate::openrouter::test_environment_scope();
5221 let tmp = TempDir::new().unwrap();
5222 let env_var = "CAR_TEST_EXPLICIT_STATUS_KEY";
5223 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
5224 let mut schema = test_generate_schema(
5225 "vendor/explicit-status",
5226 "explicit-status",
5227 ModelSource::RemoteApi {
5228 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5229 endpoint: "https://example.invalid/v1".into(),
5230 api_key_env: env_var.into(),
5231 api_key_envs: vec![],
5232 api_version: None,
5233 },
5234 );
5235 schema.provider = "vendor".into();
5236 registry.register(schema);
5237
5238 std::env::remove_var(env_var);
5239 registry.refresh_availability();
5240 assert!(
5241 !registry.get("vendor/explicit-status").unwrap().available,
5242 "no credential anywhere: the passive refresh must report unavailable"
5243 );
5244 registry.refresh_availability_for_explicit_status();
5245 assert!(
5246 !registry.get("vendor/explicit-status").unwrap().available,
5247 "no credential anywhere: the explicit refresh must agree"
5248 );
5249
5250 std::env::set_var(env_var, "sk-present");
5251 registry.refresh_availability();
5252 assert!(
5253 registry.get("vendor/explicit-status").unwrap().available,
5254 "exported credential: the passive refresh must see it"
5255 );
5256 registry.refresh_availability_for_explicit_status();
5257 assert!(
5258 registry.get("vendor/explicit-status").unwrap().available,
5259 "exported credential: the explicit refresh must see it too — if this fails \
5260 the authoritative mode is resolving through a path that cannot see the \
5261 environment, and `car models list` is answering from something else again"
5262 );
5263 std::env::remove_var(env_var);
5264 }
5265
5266 #[test]
5267 fn refresh_availability_probes_each_credential_once_not_per_model() {
5268 let _environment = crate::openrouter::test_environment_scope();
5281 let tmp = TempDir::new().unwrap();
5282 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
5283 for i in 0..25 {
5284 let mut schema = test_generate_schema(
5285 &format!("openrouter/model-{i}"),
5286 &format!("model-{i}"),
5287 ModelSource::RemoteApi {
5288 protocol: crate::schema::ApiProtocol::OpenRouter,
5289 endpoint: "https://openrouter.ai/api/v1".into(),
5290 api_key_env: "OPENROUTER_API_KEY".into(),
5291 api_key_envs: vec![],
5292 api_version: None,
5293 },
5294 );
5295 schema.provider = "openrouter".into();
5296 registry.register(schema);
5297 }
5298
5299 crate::openrouter::reset_credential_source_call_count();
5300 registry.refresh_availability();
5301 let calls = crate::openrouter::credential_source_call_count();
5302
5303 assert_eq!(
5304 calls, 1,
5305 "refresh_availability probed the OpenRouter credential {calls} times for 25 models; \
5306 it must resolve each distinct credential once per refresh, not once per model"
5307 );
5308 }
5309
5310 #[test]
5316 fn a_vllm_mlx_pull_targets_the_shared_huggingface_cache() {
5317 let repo = "mlx-community/Qwen3.8-27B-4bit";
5318 let dir = crate::hf_cache::repo_dir(repo);
5319 assert!(
5320 dir.ends_with("models--mlx-community--Qwen3.8-27B-4bit"),
5321 "got {}",
5322 dir.display()
5323 );
5324 assert!(
5325 dir.parent().is_some_and(|p| p.ends_with("hub")),
5326 "must live under the HF cache's hub/ root, got {}",
5327 dir.display()
5328 );
5329 }
5330
5331 #[test]
5334 fn external_vllm_mlx_does_not_become_managed_from_a_loopback_endpoint() {
5335 let _environment = crate::openrouter::test_environment_scope();
5348 let tmp = TempDir::new().unwrap();
5349 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
5350 let schema = test_generate_schema(
5351 "vllm-mlx/arch-the-rust-backend-cannot-load",
5352 "external-only-model",
5353 ModelSource::VllmMlx {
5354 endpoint: "http://localhost:8000".into(),
5355 model_name: "mlx-community/Qwen3.8-27B-4bit".into(),
5356 },
5357 );
5358 registry.register(schema);
5359
5360 assert!(
5362 std::env::var("VLLM_MLX_ENDPOINT").is_err(),
5363 "test precondition: VLLM_MLX_ENDPOINT must be unset"
5364 );
5365 registry.refresh_availability();
5366
5367 let model = registry
5368 .get("vllm-mlx/arch-the-rust-backend-cannot-load")
5369 .expect("registered model should be present");
5370 assert!(
5371 !model.available,
5372 "an external vllm-mlx row remains external even on loopback; only an \
5373 explicit ManagedVllmMlx source may use CAR's runtime"
5374 );
5375 }
5376
5377 #[test]
5378 fn user_config_load_and_save_force_community_trust() {
5379 let tmp = TempDir::new().unwrap();
5380 let models_dir = tmp.path().join("models");
5381 let config_path = tmp.path().join("models.json");
5382 let schema = test_generate_schema(
5383 "user/test-model",
5384 "user-test-model",
5385 ModelSource::RemoteApi {
5386 endpoint: "https://attacker.invalid/v1/chat/completions".into(),
5387 api_key_env: "CAR_USER_MODEL_TEST_KEY".into(),
5388 api_key_envs: vec![],
5389 api_version: None,
5390 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5391 },
5392 );
5393 let mut omitted_tier = serde_json::to_value(schema.clone()).unwrap();
5394 omitted_tier.as_object_mut().unwrap().remove("trust_tier");
5395 std::fs::write(
5396 &config_path,
5397 serde_json::to_vec_pretty(&vec![omitted_tier]).unwrap(),
5398 )
5399 .unwrap();
5400
5401 let mut loaded = UnifiedRegistry::new_empty(models_dir.clone());
5402 loaded.load_user_config().unwrap();
5403 assert_eq!(
5404 loaded.get("user/test-model").unwrap().trust_tier,
5405 crate::schema::TrustTier::Community
5406 );
5407
5408 let mut persisted = UnifiedRegistry::new_empty(models_dir);
5409 persisted.register_user_model(schema);
5410 persisted.save_user_config().unwrap();
5411 let saved: Vec<ModelSchema> =
5412 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5413 assert_eq!(saved.len(), 1);
5414 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
5415 }
5416
5417 #[test]
5418 fn persisted_user_model_cannot_shadow_managed_openrouter_alias() {
5419 let _environment = crate::openrouter::test_environment_scope();
5424 let tmp = TempDir::new().unwrap();
5425 let models_dir = tmp.path().join("models");
5426 let config_path = tmp.path().join("models.json");
5427 let mut shadow = crate::openrouter::curated_schemas()
5428 .into_iter()
5429 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
5430 .unwrap();
5431 shadow.provider = "attacker".into();
5432 std::fs::write(
5433 &config_path,
5434 serde_json::to_vec_pretty(&vec![shadow]).unwrap(),
5435 )
5436 .unwrap();
5437
5438 let registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
5439 let actual = registry
5440 .get("parslee/openrouter/frontier-general")
5441 .expect("compiled managed alias must remain present");
5442 assert_eq!(actual.provider, "parslee");
5443 assert_eq!(
5444 crate::openrouter::canonical_managed_gateway_selector(actual),
5445 Some("parslee/openrouter/frontier-general")
5446 );
5447 }
5448
5449 fn signed_remote(id: &str, name: &str) -> ModelSchema {
5450 test_generate_schema(
5451 id,
5452 name,
5453 ModelSource::RemoteApi {
5454 endpoint: "https://catalog.example/v1".into(),
5455 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5456 api_key_envs: vec![],
5457 api_version: None,
5458 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5459 },
5460 )
5461 }
5462
5463 #[test]
5464 fn a_user_registered_row_wins_a_live_signed_swap_and_stays_in_models_json() {
5465 let _environment = crate::openrouter::test_environment_scope();
5466 let tmp = TempDir::new().unwrap();
5467 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5468 tmp.path().to_path_buf(),
5469 tmp.path().join("models"),
5470 None,
5471 );
5472 let mut mine = signed_remote("shared/id", "mine");
5473 mine.context_length = 1234;
5474 registry.register_user_model(mine);
5475 let mut publishers = signed_remote("shared/id", "publishers");
5476 publishers.context_length = 9999;
5477 assert_eq!(
5478 registry.replace_signed_catalog(
5479 vec![publishers],
5480 None,
5481 &HashSet::new(),
5482 &HashSet::new()
5483 ),
5484 0
5485 );
5486 assert_eq!(registry.get("shared/id").unwrap().context_length, 1234);
5487 registry.save_user_config().unwrap();
5488 let saved = std::fs::read_to_string(tmp.path().join(USER_MODELS_FILE)).unwrap();
5489 assert!(saved.contains("1234") && !saved.contains("9999"), "{saved}");
5490 assert!(registry.unregister_user_model("shared/id").is_some());
5491 }
5492
5493 #[test]
5494 fn a_withdrawn_row_something_depends_on_stays_deprecated_until_nothing_does() {
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 used = signed_remote("signed/used", "used");
5503 let idle = signed_remote("signed/idle", "idle");
5504 assert_eq!(
5505 registry.replace_signed_catalog(
5506 vec![used, idle],
5507 None,
5508 &HashSet::new(),
5509 &HashSet::new()
5510 ),
5511 2
5512 );
5513 let keep: HashSet<String> = ["signed/used".to_string()].into();
5514 assert_eq!(
5515 registry.replace_signed_catalog(vec![], None, &keep, &HashSet::new()),
5516 0
5517 );
5518 assert!(
5519 registry.get("signed/idle").is_none(),
5520 "nothing depended on it"
5521 );
5522 let kept = registry.get("signed/used").expect("a dependent keeps it");
5523 assert!(kept.deprecated, "kept, but never suggested");
5524 let used = signed_remote("signed/used", "used");
5526 assert_eq!(
5527 registry.replace_signed_catalog(vec![used], None, &keep, &HashSet::new()),
5528 1
5529 );
5530 assert!(!registry.get("signed/used").unwrap().deprecated);
5531 registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5533 assert!(registry.get("signed/used").is_none());
5534 }
5535
5536 #[test]
5537 fn a_kept_row_survives_a_restart_only_while_its_signed_catalog_verifies() {
5538 let _environment = crate::openrouter::test_environment_scope();
5539 let tmp = TempDir::new().unwrap();
5540 let open = |key: Option<&str>| {
5541 UnifiedRegistry::new_with_catalog_public_key(
5542 tmp.path().to_path_buf(),
5543 tmp.path().join("models"),
5544 key,
5545 )
5546 };
5547 let cache = crate::catalog::cache_path(tmp.path());
5548 let retained = crate::catalog::retained_path(tmp.path());
5549 let catalog = |models, version| {
5550 crate::catalog::signed_test_catalog(
5551 crate::catalog::CatalogDoc {
5552 revoked: Vec::new(),
5553 version,
5554 models,
5555 },
5556 21,
5557 )
5558 };
5559 let (v1, key) = catalog(
5560 vec![
5561 signed_remote("signed/used", "used"),
5562 signed_remote("signed/idle", "idle"),
5563 ],
5564 1,
5565 );
5566 crate::catalog::save_verified(&cache, &v1).unwrap();
5567 let mut running = open(Some(&key));
5568 assert!(running.get("signed/used").is_some());
5569
5570 let (v2, _) = catalog(vec![], 2);
5572 let keep: HashSet<String> = ["signed/used".to_string()].into();
5573 running.replace_signed_catalog(
5574 v2.clone().into_models(),
5575 Some(std::sync::Arc::new(v2.envelope())),
5576 &keep,
5577 &HashSet::new(),
5578 );
5579 crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
5580 crate::catalog::save_verified(&cache, &v2).unwrap();
5581
5582 let restarted = open(Some(&key));
5583 let kept = restarted.get("signed/used").expect("kept across a restart");
5584 assert!(kept.deprecated);
5585 assert!(restarted.get("signed/idle").is_none());
5586 let (_, other_key) = crate::catalog::signed_test_catalog(
5588 crate::catalog::CatalogDoc {
5589 revoked: Vec::new(),
5590 version: 9,
5591 models: vec![],
5592 },
5593 22,
5594 );
5595 assert!(open(Some(&other_key)).get("signed/used").is_none());
5596 let json = std::fs::read_to_string(&retained).unwrap();
5598 std::fs::write(&retained, json.replace("signed/idle", "signed/idlx")).unwrap();
5599 assert!(open(Some(&key)).get("signed/used").is_none());
5600 }
5601
5602 #[test]
5603 fn a_revoked_row_leaves_even_when_something_depends_on_it() {
5604 let _environment = crate::openrouter::test_environment_scope();
5605 let tmp = TempDir::new().unwrap();
5606 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5607 tmp.path().to_path_buf(),
5608 tmp.path().join("models"),
5609 None,
5610 );
5611 let none = HashSet::new();
5612 let bad: HashSet<String> = ["signed/bad".to_string()].into();
5613 registry.replace_signed_catalog(
5614 vec![signed_remote("signed/bad", "bad")],
5615 None,
5616 &none,
5617 &none,
5618 );
5619 registry.replace_signed_catalog(vec![], None, &bad, &none);
5621 assert!(registry.get("signed/bad").is_some());
5622 registry.replace_signed_catalog(vec![], None, &bad, &bad);
5624 assert!(registry.get("signed/bad").is_none());
5625 registry.replace_signed_catalog(
5627 vec![signed_remote("signed/bad", "bad")],
5628 None,
5629 &none,
5630 &bad,
5631 );
5632 assert!(registry.get("signed/bad").is_none());
5633 assert!(registry.retained_rows().is_empty());
5634
5635 let builtin = builtin_catalog()
5637 .into_iter()
5638 .find(|m| !m.deprecated)
5639 .expect("a current builtin");
5640 let revoked: HashSet<String> = [builtin.id.clone()].into();
5641 registry.replace_signed_catalog(vec![], None, &none, &revoked);
5642 assert!(registry.get(&builtin.id).unwrap().deprecated);
5643 registry.replace_signed_catalog(vec![], None, &none, &none);
5644 assert!(!registry.get(&builtin.id).unwrap().deprecated);
5645 }
5646
5647 #[test]
5648 fn a_restart_never_restores_a_row_the_current_catalog_revokes() {
5649 let _environment = crate::openrouter::test_environment_scope();
5650 let tmp = TempDir::new().unwrap();
5651 let catalog = |models, revoked, version| {
5652 crate::catalog::signed_test_catalog(
5653 crate::catalog::CatalogDoc {
5654 version,
5655 models,
5656 revoked,
5657 },
5658 23,
5659 )
5660 };
5661 let (v1, key) = catalog(vec![signed_remote("signed/kept", "kept")], vec![], 1);
5662 let mut running = UnifiedRegistry::new_with_catalog_public_key(
5663 tmp.path().to_path_buf(),
5664 tmp.path().join("models"),
5665 Some(&key),
5666 );
5667 running.replace_signed_catalog(
5668 v1.clone().into_models(),
5669 Some(std::sync::Arc::new(v1.envelope())),
5670 &HashSet::new(),
5671 &HashSet::new(),
5672 );
5673 let (v2, _) = catalog(vec![], vec![], 2);
5675 let keep: HashSet<String> = ["signed/kept".to_string()].into();
5676 running.replace_signed_catalog(
5677 vec![],
5678 Some(std::sync::Arc::new(v2.envelope())),
5679 &keep,
5680 &HashSet::new(),
5681 );
5682 let retained = crate::catalog::retained_path(tmp.path());
5683 crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
5684 let (v3, _) = catalog(vec![], vec!["signed/kept".to_string()], 3);
5686 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &v3).unwrap();
5687 let restarted = UnifiedRegistry::new_with_catalog_public_key(
5688 tmp.path().to_path_buf(),
5689 tmp.path().join("models"),
5690 Some(&key),
5691 );
5692 assert!(restarted.get("signed/kept").is_none());
5693 }
5694
5695 #[test]
5696 fn a_revoked_id_never_comes_back_from_the_discovery_cache() {
5697 let _environment = crate::openrouter::test_environment_scope();
5698 let tmp = TempDir::new().unwrap();
5699 let (catalog, key) = crate::catalog::signed_test_catalog(
5700 crate::catalog::CatalogDoc {
5701 version: 1,
5702 models: vec![],
5703 revoked: vec!["discovered/bad".to_string()],
5704 },
5705 24,
5706 );
5707 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &catalog).unwrap();
5708 crate::discovery::save_cache(
5711 &crate::discovery::cache_path(&tmp.path().join("models")),
5712 &[
5713 signed_remote("discovered/bad", "bad"),
5714 signed_remote("discovered/fine", "fine"),
5715 ],
5716 )
5717 .unwrap();
5718 let restarted = UnifiedRegistry::new_with_catalog_public_key(
5719 tmp.path().to_path_buf(),
5720 tmp.path().join("models"),
5721 Some(&key),
5722 );
5723 assert!(restarted.get("discovered/bad").is_none());
5724 assert!(restarted.get("discovered/fine").is_some());
5725
5726 let mut running = UnifiedRegistry::new_with_catalog_public_key(
5729 tmp.path().to_path_buf(),
5730 tmp.path().join("models"),
5731 None,
5732 );
5733 assert!(running.get("discovered/bad").is_some());
5734 let revoked: HashSet<String> = ["discovered/bad".to_string()].into();
5735 running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
5736 assert!(running.get("discovered/bad").is_none());
5737 assert!(running.get("discovered/fine").is_some());
5738
5739 running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5741 assert!(running.get("discovered/bad").is_some());
5742 assert!(!running.is_revoked("discovered/bad"));
5743
5744 running.register_user_model(signed_remote("discovered/bad", "mine"));
5747 running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
5748 assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5749 running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5750 assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5751 }
5752
5753 #[test]
5754 fn a_signed_overlay_updates_a_builtins_measurements_never_its_weights() {
5755 let _environment = crate::openrouter::test_environment_scope();
5756 let tmp = TempDir::new().unwrap();
5757 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5758 tmp.path().to_path_buf(),
5759 tmp.path().join("models"),
5760 None,
5761 );
5762 let compiled = builtin_catalog()
5763 .into_iter()
5764 .find(|m| m.public_benchmarks.iter().any(|b| b.name == "car-judged"))
5765 .expect("a scored builtin");
5766 let mut remeasured = compiled.clone();
5767 remeasured.public_benchmarks[0].score = 0.42;
5768 remeasured.public_benchmarks[0].runs = Some(3);
5769 remeasured.context_length = compiled.context_length + 1;
5770 remeasured.name = "renamed by the publisher".into();
5771 assert_eq!(
5772 registry.replace_signed_catalog(
5773 vec![remeasured.clone()],
5774 None,
5775 &HashSet::new(),
5776 &HashSet::new()
5777 ),
5778 1
5779 );
5780 let row = registry.get(&compiled.id).unwrap();
5781 assert_eq!(row.public_benchmarks[0].score, 0.42);
5782 assert_eq!(row.public_benchmarks[0].runs, Some(3));
5783 assert_eq!(
5784 row.context_length, compiled.context_length,
5785 "not an overlay field"
5786 );
5787 assert_eq!(row.name, compiled.name, "not an overlay field");
5788 registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5790 assert_eq!(
5791 registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5792 compiled.public_benchmarks[0].score
5793 );
5794 let mut repointed = remeasured;
5796 repointed.source = signed_remote("x/y", "y").source;
5797 assert_eq!(
5798 registry.replace_signed_catalog(
5799 vec![repointed],
5800 None,
5801 &HashSet::new(),
5802 &HashSet::new()
5803 ),
5804 0
5805 );
5806 assert_eq!(
5807 registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5808 compiled.public_benchmarks[0].score
5809 );
5810 }
5811
5812 #[test]
5813 fn user_config_persistence_excludes_signed_rows_and_keeps_builtin_tagged_user_rows() {
5814 let _environment = crate::openrouter::test_environment_scope();
5819 let tmp = TempDir::new().unwrap();
5820 let models_dir = tmp.path().join("models");
5821 let config_path = tmp.path().join("models.json");
5822
5823 let signed = test_generate_schema(
5824 "signed/catalog-only",
5825 "signed-catalog-only",
5826 ModelSource::RemoteApi {
5827 endpoint: "https://catalog.example/v1".into(),
5828 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5829 api_key_envs: vec![],
5830 api_version: None,
5831 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5832 },
5833 );
5834 assert!(!signed.tags.iter().any(|tag| tag == "builtin"));
5835 let (verified, public_key) = crate::catalog::signed_test_catalog(
5836 crate::catalog::CatalogDoc {
5837 revoked: Vec::new(),
5838 version: 81,
5839 models: vec![signed],
5840 },
5841 81,
5842 );
5843 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
5844
5845 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5846 tmp.path().to_path_buf(),
5847 models_dir.clone(),
5848 Some(public_key.as_str()),
5849 );
5850 let mut user = test_generate_schema(
5851 "user/builtin-tagged",
5852 "user-builtin-tagged",
5853 ModelSource::RemoteApi {
5854 endpoint: "https://user.example/v1".into(),
5855 api_key_env: "USER_MODEL_TEST_KEY".into(),
5856 api_key_envs: vec![],
5857 api_version: None,
5858 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5859 },
5860 );
5861 user.tags.push("builtin".into());
5862 registry.register_user_model(user);
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_eq!(
5868 saved
5869 .iter()
5870 .map(|model| model.id.as_str())
5871 .collect::<Vec<_>>(),
5872 vec!["user/builtin-tagged"],
5873 "models.json must contain only explicitly user-registered rows"
5874 );
5875 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
5876
5877 let mut restarted = UnifiedRegistry::new_with_catalog_public_key(
5878 tmp.path().to_path_buf(),
5879 models_dir,
5880 Some(public_key.as_str()),
5881 );
5882 assert_eq!(
5883 restarted.get("signed/catalog-only").unwrap().trust_tier,
5884 crate::schema::TrustTier::Curated,
5885 "user persistence must not demote an unrelated signed catalog row"
5886 );
5887 assert_eq!(
5888 restarted.get("user/builtin-tagged").unwrap().trust_tier,
5889 crate::schema::TrustTier::Community
5890 );
5891 let mut signed_shadow = restarted.get("signed/catalog-only").unwrap().clone();
5892 signed_shadow.name = "user-shadow-of-signed-row".into();
5893 restarted.register_user_model(signed_shadow);
5894 assert_eq!(
5895 restarted.get("signed/catalog-only").unwrap().name,
5896 "signed-catalog-only",
5897 "a user row must not shadow a signature-verified project exact id"
5898 );
5899 }
5900
5901 #[test]
5902 fn empty_user_config_save_clears_stale_rows() {
5903 let tmp = TempDir::new().unwrap();
5904 let models_dir = tmp.path().join("models");
5905 let config_path = tmp.path().join("models.json");
5906 let stale = test_generate_schema(
5907 "user/stale",
5908 "stale",
5909 ModelSource::RemoteApi {
5910 endpoint: "https://stale.example/v1".into(),
5911 api_key_env: "STALE_USER_MODEL_TEST_KEY".into(),
5912 api_key_envs: vec![],
5913 api_version: None,
5914 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5915 },
5916 );
5917 std::fs::write(
5918 &config_path,
5919 serde_json::to_vec_pretty(&vec![stale]).unwrap(),
5920 )
5921 .unwrap();
5922
5923 UnifiedRegistry::new_empty(models_dir)
5924 .save_user_config()
5925 .unwrap();
5926
5927 let saved: Vec<ModelSchema> =
5928 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5929 assert!(
5930 saved.is_empty(),
5931 "saving an empty user set must overwrite stale models.json rows"
5932 );
5933 }
5934
5935 #[test]
5936 fn unregister_then_save_removes_the_user_row_from_disk() {
5937 let tmp = TempDir::new().unwrap();
5938 let models_dir = tmp.path().join("models");
5939 let config_path = tmp.path().join("models.json");
5940 let mut registry = UnifiedRegistry::new_empty(models_dir);
5941 registry.register_project_model(test_generate_schema(
5942 "signed/not-user-removable",
5943 "not-user-removable",
5944 ModelSource::RemoteApi {
5945 endpoint: "https://catalog.example/v1".into(),
5946 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5947 api_key_envs: vec![],
5948 api_version: None,
5949 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5950 },
5951 ));
5952 assert!(
5953 registry
5954 .unregister_user_model("signed/not-user-removable")
5955 .is_none(),
5956 "the user boundary cannot unregister an untracked catalog row"
5957 );
5958 assert!(registry.get("signed/not-user-removable").is_some());
5959 registry.register_user_model(test_generate_schema(
5960 "user/removable",
5961 "removable",
5962 ModelSource::RemoteApi {
5963 endpoint: "https://user.example/v1".into(),
5964 api_key_env: "REMOVABLE_USER_MODEL_TEST_KEY".into(),
5965 api_key_envs: vec![],
5966 api_version: None,
5967 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5968 },
5969 ));
5970 registry.save_user_config().unwrap();
5971 assert!(registry.unregister_user_model("user/removable").is_some());
5972 registry.save_user_config().unwrap();
5973
5974 let saved: Vec<ModelSchema> =
5975 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5976 assert!(saved.is_empty());
5977 }
5978
5979 #[test]
5991 fn a_registration_written_under_car_home_is_the_file_the_registry_reads() {
5992 let _environment = crate::openrouter::test_environment_scope();
5993 let prior = std::env::var_os(car_home::ENV_VAR);
5994
5995 let state_root = TempDir::new().unwrap();
5996 let weights = TempDir::new().unwrap();
5999 let models_dir = weights.path().join("models");
6000 std::fs::create_dir_all(&models_dir).unwrap();
6001
6002 unsafe { std::env::set_var(car_home::ENV_VAR, state_root.path()) };
6003
6004 let write_path = user_config_path().expect("CAR_HOME must resolve a models.json path");
6006 assert_eq!(write_path, state_root.path().join(USER_MODELS_FILE));
6007 let registered = test_generate_schema(
6008 "user/relocated-daemon-model",
6009 "relocated-daemon-model",
6010 ModelSource::RemoteApi {
6011 endpoint: "https://relocated.example/v1".into(),
6012 api_key_env: "RELOCATED_DAEMON_MODEL_TEST_KEY".into(),
6013 api_key_envs: vec![],
6014 api_version: None,
6015 protocol: crate::schema::ApiProtocol::OpenAiCompat,
6016 },
6017 );
6018 std::fs::write(
6019 &write_path,
6020 serde_json::to_vec_pretty(&vec![registered]).unwrap(),
6021 )
6022 .unwrap();
6023
6024 let registry = UnifiedRegistry::new(models_dir.clone());
6026
6027 match prior {
6028 Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
6029 None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
6030 }
6031
6032 assert!(
6033 registry.get("user/relocated-daemon-model").is_some(),
6034 "the registry must load the models.json that `models.register` wrote; \
6035 it looked at {} instead",
6036 registry.user_config_path.display(),
6037 );
6038 assert_eq!(registry.user_config_path, write_path);
6039 assert!(
6040 !weights.path().join(USER_MODELS_FILE).exists(),
6041 "nothing may be written beside the shared weights cache",
6042 );
6043 }
6044
6045 #[test]
6046 fn ready_without_download_is_strict_for_local_model_files() {
6047 let tmp = TempDir::new().unwrap();
6048 let models = tmp.path().join("models");
6049 let mut reg = UnifiedRegistry::new_empty(models.clone());
6050 reg.register(test_generate_schema(
6051 "local/test",
6052 "TestLocal",
6053 ModelSource::Local {
6054 hf_repo: "example/repo".into(),
6055 hf_filename: "model.gguf".into(),
6056 tokenizer_repo: "example/repo".into(),
6057 },
6058 ));
6059
6060 assert_eq!(reg.ready_without_download("local/test"), Some(false));
6061
6062 let dir = models.join("TestLocal");
6063 std::fs::create_dir_all(&dir).unwrap();
6064 std::fs::write(dir.join("model.gguf"), b"weights").unwrap();
6065 assert_eq!(
6066 reg.ready_without_download("local/test"),
6067 Some(false),
6068 "tokenizer is required too"
6069 );
6070 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
6071 assert_eq!(reg.ready_without_download("local/test"), Some(true));
6072 }
6073
6074 #[test]
6075 fn ready_without_download_rejects_mlx_config_only_stub() {
6076 let tmp = TempDir::new().unwrap();
6077 let models = tmp.path().join("models");
6078 let mut reg = UnifiedRegistry::new_empty(models.clone());
6079 reg.register(test_generate_schema(
6080 "mlx/test",
6081 "TestMlx",
6082 ModelSource::Mlx {
6083 hf_repo: "example/repo".into(),
6084 hf_weight_file: None,
6085 },
6086 ));
6087
6088 let dir = models.join("TestMlx");
6089 std::fs::create_dir_all(&dir).unwrap();
6090 std::fs::write(dir.join("config.json"), "{}").unwrap();
6091 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
6092 assert_eq!(
6093 reg.ready_without_download("mlx/test"),
6094 Some(false),
6095 "config/tokenizer stubs must not start assistant inference"
6096 );
6097
6098 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
6099 assert_eq!(reg.ready_without_download("mlx/test"), Some(true));
6100 }
6101
6102 fn write_mlx_dir(root: &Path, name: &str, model_type: &str) {
6103 let dir = root.join(name);
6104 std::fs::create_dir_all(&dir).unwrap();
6105 std::fs::write(
6106 dir.join("config.json"),
6107 serde_json::json!({
6108 "model_type": model_type,
6109 "max_position_embeddings": 40_960,
6110 "quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" },
6111 })
6112 .to_string(),
6113 )
6114 .unwrap();
6115 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
6116 }
6117
6118 struct ScopedEnvVar {
6119 name: &'static str,
6120 previous: Option<std::ffi::OsString>,
6121 }
6122
6123 impl ScopedEnvVar {
6124 fn set(name: &'static str, value: &Path) -> Self {
6125 let previous = std::env::var_os(name);
6126 unsafe { std::env::set_var(name, value) };
6129 Self { name, previous }
6130 }
6131
6132 fn unset(name: &'static str) -> Self {
6133 let previous = std::env::var_os(name);
6134 unsafe { std::env::remove_var(name) };
6136 Self { name, previous }
6137 }
6138 }
6139
6140 impl Drop for ScopedEnvVar {
6141 fn drop(&mut self) {
6142 unsafe {
6145 match self.previous.take() {
6146 Some(value) => std::env::set_var(self.name, value),
6147 None => std::env::remove_var(self.name),
6148 }
6149 }
6150 }
6151 }
6152
6153 #[derive(Default)]
6154 struct AcquisitionRecorder {
6155 events: std::sync::Mutex<Vec<DownloadEvent>>,
6156 replace_dir_on_started: std::sync::Mutex<Option<PathBuf>>,
6157 }
6158
6159 impl AcquisitionRecorder {
6160 fn replacing_dir_on_started(path: PathBuf) -> Self {
6161 Self {
6162 events: std::sync::Mutex::new(Vec::new()),
6163 replace_dir_on_started: std::sync::Mutex::new(Some(path)),
6164 }
6165 }
6166
6167 fn events(&self) -> Vec<DownloadEvent> {
6168 self.events.lock().unwrap().clone()
6169 }
6170 }
6171
6172 impl crate::download::DownloadProgress for AcquisitionRecorder {
6173 fn on_event(&self, event: &DownloadEvent) {
6174 self.events.lock().unwrap().push(event.clone());
6175 if matches!(event, DownloadEvent::Started { .. }) {
6176 if let Some(path) = self.replace_dir_on_started.lock().unwrap().take() {
6177 std::fs::remove_dir_all(&path).unwrap();
6178 std::fs::write(path, b"make create_dir_all fail before any network access")
6179 .unwrap();
6180 }
6181 }
6182 }
6183 }
6184
6185 fn started_count(events: &[DownloadEvent]) -> usize {
6186 events
6187 .iter()
6188 .filter(|event| matches!(event, DownloadEvent::Started { .. }))
6189 .count()
6190 }
6191
6192 fn mlx_schema(id: &str, name: &str, hf_repo: &str) -> ModelSchema {
6193 test_generate_schema(
6194 id,
6195 name,
6196 ModelSource::Mlx {
6197 hf_repo: hf_repo.into(),
6198 hf_weight_file: None,
6199 },
6200 )
6201 }
6202
6203 #[test]
6204 fn installing_a_fetched_file_replaces_only_unusable_destinations() {
6205 let tmp = TempDir::new().unwrap();
6206 let src = tmp.path().join("fetched");
6207 std::fs::write(&src, b"fetched bytes").unwrap();
6208
6209 let missing = tmp.path().join("missing");
6210 install_fetched_file(&src, &missing).unwrap();
6211 assert_eq!(std::fs::read(&missing).unwrap(), b"fetched bytes");
6212
6213 let usable = tmp.path().join("usable");
6214 std::fs::write(&usable, b"keep these bytes").unwrap();
6215 let usable_before = std::fs::symlink_metadata(&usable).unwrap();
6216 let modified_before = usable_before.modified().unwrap();
6217 #[cfg(unix)]
6218 let inode_before = {
6219 use std::os::unix::fs::MetadataExt;
6220 usable_before.ino()
6221 };
6222 install_fetched_file(&src, &usable).unwrap();
6223 let usable_after = std::fs::symlink_metadata(&usable).unwrap();
6224 assert_eq!(std::fs::read(&usable).unwrap(), b"keep these bytes");
6225 assert_eq!(usable_after.modified().unwrap(), modified_before);
6226 #[cfg(unix)]
6227 {
6228 use std::os::unix::fs::MetadataExt;
6229 assert_eq!(usable_after.ino(), inode_before);
6230 }
6231
6232 let zero_byte = tmp.path().join("zero-byte");
6233 std::fs::write(&zero_byte, b"").unwrap();
6234 install_fetched_file(&src, &zero_byte).unwrap();
6235 assert_eq!(std::fs::read(&zero_byte).unwrap(), b"fetched bytes");
6236
6237 #[cfg(unix)]
6238 {
6239 let dangling = tmp.path().join("dangling");
6240 std::os::unix::fs::symlink(tmp.path().join("absent"), &dangling).unwrap();
6241 assert!(std::fs::symlink_metadata(&dangling).unwrap().is_symlink());
6242 install_fetched_file(&src, &dangling).unwrap();
6243 assert_eq!(std::fs::read(&dangling).unwrap(), b"fetched bytes");
6244 }
6245 }
6246
6247 #[cfg(unix)]
6250 #[tokio::test]
6251 async fn a_zero_byte_flux_auxiliary_file_is_not_accepted_as_present() {
6252 let _environment = crate::openrouter::test_environment_scope_async().await;
6253 let tmp = TempDir::new().unwrap();
6254 let home = tmp.path().join("home");
6255 let _home = ScopedEnvVar::set("HOME", &home);
6256 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6257 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6258 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6259
6260 let cached_auxiliary = tmp
6264 .path()
6265 .join("hf-home/hub")
6266 .join("models--Freepik--flux.1-lite-8B")
6267 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
6268 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
6269 std::fs::write(&cached_auxiliary, b"repaired tokenizer").unwrap();
6270 let refs = tmp
6271 .path()
6272 .join("hf-home/hub/models--Freepik--flux.1-lite-8B/refs");
6273 std::fs::create_dir_all(&refs).unwrap();
6274 std::fs::write(refs.join("main"), b"fixture").unwrap();
6275
6276 let models = tmp.path().join("models");
6277 let name = "Flux-1.lite-8B-MLX-Q4";
6278 write_mlx_dir(&models, name, "flux");
6279 let auxiliary = models.join(name).join("tokenizer_2/tokenizer.json");
6280 std::fs::create_dir_all(auxiliary.parent().unwrap()).unwrap();
6281 std::fs::write(&auxiliary, b"").unwrap();
6282 let mut reg = UnifiedRegistry::new_empty(models.clone());
6283 reg.register(mlx_schema(
6284 "mlx/flux-zero-byte-aux",
6285 name,
6286 "mlx-community/Flux-1.lite-8B-MLX-Q4",
6287 ));
6288 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6289 let sink = ProgressSink::new(recorder.clone());
6290
6291 let result = reg
6292 .acquire_and_ensure("mlx/flux-zero-byte-aux", &sink, false, None)
6293 .await;
6294 let events = recorder.events();
6295 assert!(matches!(
6296 events.first(),
6297 Some(DownloadEvent::Started { .. })
6298 ));
6299 assert_eq!(started_count(&events), 1);
6300 assert!(
6301 result.is_err() || crate::download::cache_file_usable(&auxiliary),
6302 "acquisition must replace the zero-byte auxiliary file or report failure"
6303 );
6304 if result.is_ok() {
6305 assert_eq!(std::fs::read(auxiliary).unwrap(), b"repaired tokenizer");
6306 }
6307 assert!(
6310 !tmp.path()
6311 .join("model-management")
6312 .join(crate::retire::PROVENANCE_FILE)
6313 .exists(),
6314 "reusing a cached file must not record it as a CAR download"
6315 );
6316 }
6317
6318 #[cfg(unix)]
6321 #[tokio::test]
6322 async fn managed_flux_missing_auxiliary_does_not_fall_through_to_hf_snapshot() {
6323 let _environment = crate::openrouter::test_environment_scope_async().await;
6324 let tmp = TempDir::new().unwrap();
6325 let home = tmp.path().join("home");
6326 let hf_home = tmp.path().join("hf-home");
6327 let _home = ScopedEnvVar::set("HOME", &home);
6328 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
6329 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6330 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6331
6332 let cached_auxiliary = hf_home
6336 .join("hub")
6337 .join("models--Freepik--flux.1-lite-8B")
6338 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
6339 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
6340 std::fs::write(&cached_auxiliary, b"managed repair tokenizer").unwrap();
6341 let refs = hf_home.join("hub/models--Freepik--flux.1-lite-8B/refs");
6342 std::fs::create_dir_all(&refs).unwrap();
6343 std::fs::write(refs.join("main"), b"fixture").unwrap();
6344
6345 let models = tmp.path().join("models");
6346 let name = "Flux-1.lite-8B-MLX-Q4";
6347 let managed = models.join(name);
6348 write_mlx_dir(&models, name, "flux");
6349 let managed_auxiliary = managed.join("tokenizer_2/tokenizer.json");
6350
6351 let snapshot = hf_home
6354 .join("hub/models--mlx-community--Flux-1.lite-8B-MLX-Q4")
6355 .join("snapshots/fixture");
6356 write_complete_mlx_snapshot(&snapshot);
6357 let snapshot_auxiliary = snapshot.join("tokenizer_2/tokenizer.json");
6358 std::fs::create_dir_all(snapshot_auxiliary.parent().unwrap()).unwrap();
6359 std::fs::write(&snapshot_auxiliary, b"snapshot tokenizer").unwrap();
6360
6361 let id = "mlx/flux-managed-precedence";
6362 let mut reg = UnifiedRegistry::new_empty(models);
6363 reg.register(mlx_schema(id, name, "mlx-community/Flux-1.lite-8B-MLX-Q4"));
6364 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6365 let sink = ProgressSink::new(recorder.clone());
6366
6367 let path = reg
6368 .acquire_and_ensure(id, &sink, false, None)
6369 .await
6370 .unwrap();
6371
6372 assert_eq!(path, managed);
6373 assert_eq!(
6374 std::fs::read(managed_auxiliary).unwrap(),
6375 b"managed repair tokenizer"
6376 );
6377 let events = recorder.events();
6378 assert!(matches!(
6379 events.first(),
6380 Some(DownloadEvent::Started { .. })
6381 ));
6382 assert_eq!(started_count(&events), 1);
6383 }
6384
6385 #[tokio::test]
6386 async fn reusing_a_complete_managed_mlx_dir_emits_no_acquisition_lifecycle() {
6387 let _environment = crate::openrouter::test_environment_scope_async().await;
6388 let tmp = TempDir::new().unwrap();
6389 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6390 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6391 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6392 let models = tmp.path().join("models");
6393 let mut reg = UnifiedRegistry::new_empty(models.clone());
6394 reg.register(mlx_schema(
6395 "mlx/reuse-complete",
6396 "Reuse-Complete-MLX",
6397 "example/reuse-complete",
6398 ));
6399 write_mlx_dir(&models, "Reuse-Complete-MLX", "qwen3");
6400 std::fs::write(
6401 models.join("Reuse-Complete-MLX").join("tokenizer.json"),
6402 b"{}",
6403 )
6404 .unwrap();
6405 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6406 let sink = ProgressSink::new(recorder.clone());
6407
6408 let path = reg
6409 .ensure_local_with_progress("mlx/reuse-complete", &sink)
6410 .await
6411 .unwrap();
6412
6413 assert_eq!(path, models.join("Reuse-Complete-MLX"));
6414 assert!(recorder.events().is_empty());
6415 }
6416
6417 #[tokio::test]
6418 async fn reusing_a_managed_mlx_dir_without_tokenizer_json_emits_no_lifecycle() {
6419 let _environment = crate::openrouter::test_environment_scope_async().await;
6420 let tmp = TempDir::new().unwrap();
6421 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6422 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6423 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6424 let models = tmp.path().join("models");
6425 let mut reg = UnifiedRegistry::new_empty(models.clone());
6426 reg.register(mlx_schema(
6427 "mlx/reuse-no-tokenizer",
6428 "Reuse-No-Tokenizer-MLX",
6429 "example/reuse-no-tokenizer",
6430 ));
6431 write_mlx_dir(&models, "Reuse-No-Tokenizer-MLX", "qwen3");
6432 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6433 let sink = ProgressSink::new(recorder.clone());
6434
6435 let path = reg
6436 .ensure_local_with_progress("mlx/reuse-no-tokenizer", &sink)
6437 .await
6438 .unwrap();
6439
6440 assert_eq!(path, models.join("Reuse-No-Tokenizer-MLX"));
6441 assert!(recorder.events().is_empty());
6442 }
6443
6444 #[tokio::test]
6445 async fn reusing_a_complete_hf_snapshot_emits_no_acquisition_lifecycle() {
6446 let _environment = crate::openrouter::test_environment_scope_async().await;
6447 let tmp = TempDir::new().unwrap();
6448 let hf_home = tmp.path().join("hf-home");
6449 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
6450 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6451 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6452 let models = tmp.path().join("models");
6453 let repo = "example/reuse-hf-snapshot";
6454 let snapshot = hf_home
6455 .join("hub")
6456 .join("models--example--reuse-hf-snapshot")
6457 .join("snapshots")
6458 .join("fixture");
6459 write_complete_mlx_snapshot(&snapshot);
6460 let mut reg = UnifiedRegistry::new_empty(models);
6461 reg.register(mlx_schema("mlx/reuse-hf", "Reuse-HF-MLX", repo));
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-hf", &sink)
6467 .await
6468 .unwrap();
6469
6470 assert_eq!(path, snapshot);
6471 assert!(recorder.events().is_empty());
6472 }
6473
6474 #[tokio::test]
6475 async fn a_zero_byte_managed_config_json_starts_acquisition() {
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 name = "Zero-Byte-Config-MLX";
6483 write_mlx_dir(&models, name, "qwen3");
6484 let model_dir = models.join(name);
6485 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
6486 std::fs::write(model_dir.join("config.json"), b"").unwrap();
6487 let mut reg = UnifiedRegistry::new_empty(models);
6488 reg.register(mlx_schema(
6489 "mlx/zero-byte-config",
6490 name,
6491 "example/zero-byte-config",
6492 ));
6493 let recorder =
6494 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
6495 let sink = ProgressSink::new(recorder.clone());
6496
6497 let result = reg
6498 .ensure_local_with_progress("mlx/zero-byte-config", &sink)
6499 .await;
6500
6501 let events = recorder.events();
6502 assert!(
6503 matches!(events.first(), Some(DownloadEvent::Started { .. })),
6504 "weights beside a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
6505 );
6506 assert_eq!(started_count(&events), 1);
6507 assert!(result.is_err());
6508 }
6509
6510 #[tokio::test]
6511 async fn a_zero_byte_hf_snapshot_config_json_starts_acquisition() {
6512 let _environment = crate::openrouter::test_environment_scope_async().await;
6513 let tmp = TempDir::new().unwrap();
6514 let hf_home = tmp.path().join("hf-home");
6515 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
6516 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6517 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6518 let models = tmp.path().join("models");
6519 let name = "Zero-Byte-Snapshot-Config-MLX";
6520 let repo = "example/zero-byte-snapshot-config";
6521 let snapshot = hf_home
6522 .join("hub")
6523 .join("models--example--zero-byte-snapshot-config")
6524 .join("snapshots")
6525 .join("fixture");
6526 write_complete_mlx_snapshot(&snapshot);
6527 std::fs::write(snapshot.join("config.json"), b"").unwrap();
6528 let model_dir = models.join(name);
6531 std::fs::create_dir_all(&model_dir).unwrap();
6532 let mut reg = UnifiedRegistry::new_empty(models);
6533 reg.register(mlx_schema("mlx/zero-byte-snapshot-config", name, repo));
6534 let recorder =
6535 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
6536 let sink = ProgressSink::new(recorder.clone());
6537
6538 let result = reg
6539 .ensure_local_with_progress("mlx/zero-byte-snapshot-config", &sink)
6540 .await;
6541
6542 let events = recorder.events();
6543 assert!(
6544 matches!(events.first(), Some(DownloadEvent::Started { .. })),
6545 "a snapshot with a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
6546 );
6547 assert_eq!(started_count(&events), 1);
6548 assert!(result.is_err());
6549 }
6550
6551 #[cfg(unix)]
6552 #[tokio::test]
6553 async fn an_oversized_installed_model_is_reused_below_the_disk_threshold() {
6554 let _environment = crate::openrouter::test_environment_scope_async().await;
6555 let tmp = TempDir::new().unwrap();
6556 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6557 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6558 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6559 let models = tmp.path().join("models");
6560 let mut schema = mlx_schema(
6561 "mlx/reuse-oversized",
6562 "Reuse-Oversized-MLX",
6563 "example/reuse-oversized",
6564 );
6565 schema.cost.size_mb = Some(1_000_000_000);
6568 let mut reg = UnifiedRegistry::new_empty(models.clone());
6569 reg.register(schema);
6570 write_mlx_dir(&models, "Reuse-Oversized-MLX", "qwen3");
6571 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6572 let sink = ProgressSink::new(recorder.clone());
6573
6574 let path = reg
6575 .ensure_local_with_progress("mlx/reuse-oversized", &sink)
6576 .await
6577 .unwrap();
6578
6579 assert_eq!(path, models.join("Reuse-Oversized-MLX"));
6580 assert!(recorder.events().is_empty());
6581 }
6582
6583 #[tokio::test]
6584 async fn a_missing_shard_starts_acquisition_exactly_once_after_the_preflight() {
6585 let _environment = crate::openrouter::test_environment_scope_async().await;
6586 let tmp = TempDir::new().unwrap();
6587 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6588 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6589 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6590 let models = tmp.path().join("models");
6591 let model_dir = models.join("Missing-Shard-MLX");
6592 std::fs::create_dir_all(&model_dir).unwrap();
6593 std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
6594 std::fs::write(
6595 model_dir.join("model.safetensors.index.json"),
6596 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
6597 )
6598 .unwrap();
6599 std::fs::write(model_dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
6600 let mut reg = UnifiedRegistry::new_empty(models);
6601 reg.register(mlx_schema(
6602 "mlx/missing-shard-lifecycle",
6603 "Missing-Shard-MLX",
6604 "example/missing-shard",
6605 ));
6606 let recorder =
6607 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
6608 let sink = ProgressSink::new(recorder.clone());
6609
6610 assert!(reg
6611 .ensure_local_with_progress("mlx/missing-shard-lifecycle", &sink)
6612 .await
6613 .is_err());
6614 let events = recorder.events();
6615 assert!(matches!(
6616 events.first(),
6617 Some(DownloadEvent::Started { .. })
6618 ));
6619 assert_eq!(started_count(&events), 1);
6620 }
6621
6622 #[tokio::test]
6623 async fn a_flux_dir_missing_its_auxiliary_tokenizer_keeps_the_lifecycle() {
6624 let _environment = crate::openrouter::test_environment_scope_async().await;
6625 let tmp = TempDir::new().unwrap();
6626 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6627 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6628 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6629 let models = tmp.path().join("models");
6630 let name = "Flux-1.lite-8B-MLX-Q4";
6631 write_mlx_dir(&models, name, "flux");
6632 std::fs::write(models.join(name).join("tokenizer_2"), b"not a directory").unwrap();
6633 let mut reg = UnifiedRegistry::new_empty(models);
6634 reg.register(mlx_schema(
6635 "mlx/flux-missing-aux",
6636 name,
6637 "mlx-community/Flux-1.lite-8B-MLX-Q4",
6638 ));
6639 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6640 let sink = ProgressSink::new(recorder.clone());
6641
6642 assert!(reg
6643 .ensure_local_with_progress("mlx/flux-missing-aux", &sink)
6644 .await
6645 .is_err());
6646 let events = recorder.events();
6647 assert!(matches!(
6648 events.first(),
6649 Some(DownloadEvent::Started { .. })
6650 ));
6651 assert_eq!(started_count(&events), 1);
6652 }
6653
6654 #[tokio::test]
6655 async fn force_bypasses_reuse() {
6656 let _environment = crate::openrouter::test_environment_scope_async().await;
6657 let tmp = TempDir::new().unwrap();
6658 let models = tmp.path().join("models");
6659 let name = "Force-Local";
6660 let model_dir = models.join(name);
6661 std::fs::create_dir_all(&model_dir).unwrap();
6662 std::fs::write(model_dir.join("model.gguf"), b"weights").unwrap();
6663 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
6664 let mut reg = UnifiedRegistry::new_empty(models);
6665 reg.register(test_generate_schema(
6666 "local/force-lifecycle",
6667 name,
6668 ModelSource::Local {
6669 hf_repo: String::new(),
6670 hf_filename: "model.gguf".into(),
6671 tokenizer_repo: String::new(),
6672 },
6673 ));
6674 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6675 let sink = ProgressSink::new(recorder.clone());
6676
6677 assert!(reg
6678 .acquire_and_ensure("local/force-lifecycle", &sink, true, None)
6679 .await
6680 .is_err());
6681 let events = recorder.events();
6682 assert!(matches!(
6683 events.first(),
6684 Some(DownloadEvent::Started { .. })
6685 ));
6686 assert_eq!(started_count(&events), 1);
6687 }
6688
6689 #[tokio::test]
6690 async fn staged_pulls_keep_the_lifecycle() {
6691 let _environment = crate::openrouter::test_environment_scope_async().await;
6692 let tmp = TempDir::new().unwrap();
6693 let models = tmp.path().join("models");
6694 let canonical = models.join("Staged-Local");
6695 std::fs::create_dir_all(&canonical).unwrap();
6696 std::fs::write(canonical.join("model.gguf"), b"canonical weights").unwrap();
6697 std::fs::write(canonical.join("tokenizer.json"), b"{}").unwrap();
6698 let mut reg = UnifiedRegistry::new_empty(models);
6699 reg.register(test_generate_schema(
6700 "local/staged-lifecycle",
6701 "Staged-Local",
6702 ModelSource::Local {
6703 hf_repo: String::new(),
6704 hf_filename: "model.gguf".into(),
6705 tokenizer_repo: String::new(),
6706 },
6707 ));
6708 let staging = tmp.path().join("staging");
6709 std::fs::create_dir_all(&staging).unwrap();
6710 std::fs::write(staging.join("model.gguf"), b"weights").unwrap();
6711 std::fs::write(staging.join("tokenizer.json"), b"{}").unwrap();
6712 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6713 let sink = ProgressSink::new(recorder.clone());
6714
6715 let path = reg
6716 .acquire_and_ensure("local/staged-lifecycle", &sink, false, Some(&staging))
6717 .await
6718 .unwrap();
6719 assert_eq!(path, staging);
6720 let events = recorder.events();
6721 assert!(matches!(
6722 events.first(),
6723 Some(DownloadEvent::Started { .. })
6724 ));
6725 assert_eq!(started_count(&events), 1);
6726 }
6727
6728 #[test]
6729 fn synthesize_local_schema_classifies_by_name_and_arch() {
6730 let tmp = TempDir::new().unwrap();
6731 let root = tmp.path();
6732
6733 write_mlx_dir(root, "MyCustom-Qwen3-7B", "qwen3");
6734 let gen = synthesize_local_schema("MyCustom-Qwen3-7B", &root.join("MyCustom-Qwen3-7B"))
6735 .expect("text LLM should be recognized");
6736 assert_eq!(
6737 gen.capabilities,
6738 vec![
6739 ModelCapability::Generate,
6740 ModelCapability::Code,
6741 ModelCapability::Reasoning
6742 ]
6743 );
6744 assert_eq!(gen.context_length, 40_960);
6745 assert_eq!(gen.provider, "local");
6746 assert!(matches!(gen.source, ModelSource::Mlx { .. }));
6747 assert_eq!(gen.family, "local");
6753 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6754 assert!(!crate::InferenceEngine::supports_native_mlx(&gen));
6755
6756 write_mlx_dir(root, "Some-Embedding-0.6B", "qwen3");
6757 let emb = synthesize_local_schema("Some-Embedding-0.6B", &root.join("Some-Embedding-0.6B"))
6758 .expect("embedding model recognized");
6759 assert_eq!(emb.capabilities, vec![ModelCapability::Embed]);
6760
6761 write_mlx_dir(root, "Mystery-Net", "some_unknown_arch");
6763 assert!(synthesize_local_schema("Mystery-Net", &root.join("Mystery-Net")).is_none());
6764
6765 write_mlx_dir(root, "silero-vad-v6-mlx", "qwen3");
6767 assert!(
6768 synthesize_local_schema("silero-vad-v6-mlx", &root.join("silero-vad-v6-mlx")).is_none()
6769 );
6770
6771 std::fs::create_dir_all(root.join("empty")).unwrap();
6773 assert!(synthesize_local_schema("empty", &root.join("empty")).is_none());
6774 }
6775
6776 #[test]
6782 fn a_scanned_gguf_directory_diagnoses_instead_of_downloading_from_nowhere() {
6783 let _environment = crate::openrouter::test_environment_scope();
6784 let tmp = TempDir::new().unwrap();
6785 let models = tmp.path().join("models");
6786 let dir = models.join("Dropped-In-Llama");
6787 std::fs::create_dir_all(&dir).unwrap();
6788 std::fs::write(dir.join("Llama-3-8B-Q4_K_M.gguf"), b"weights").unwrap();
6790
6791 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6794 let err = tokio::runtime::Runtime::new()
6795 .unwrap()
6796 .block_on(reg.ensure_local("Dropped-In-Llama"))
6797 .expect_err("an unloadable layout must not report success");
6798 let err = err.to_string();
6799
6800 assert!(err.contains("model.gguf"), "must name what it reads: {err}");
6801 assert!(
6802 err.contains("tokenizer.json"),
6803 "must name the missing tokenizer too: {err}"
6804 );
6805 assert!(
6806 !err.contains("huggingface.co//"),
6807 "must not have tried to fetch from an empty repo: {err}"
6808 );
6809 }
6810
6811 #[test]
6812 fn discovery_registers_uncatalogued_local_model() {
6813 let _environment = crate::openrouter::test_environment_scope();
6818 let tmp = TempDir::new().unwrap();
6819 let models = tmp.path().join("models");
6820 std::fs::create_dir_all(&models).unwrap();
6821 write_mlx_dir(&models, "Totally-Custom-Llama-3B", "llama");
6822
6823 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6824 let found = reg
6825 .list()
6826 .into_iter()
6827 .find(|m| m.name == "Totally-Custom-Llama-3B");
6828 assert!(
6829 found.is_some(),
6830 "uncatalogued on-disk model should be registered"
6831 );
6832 assert!(found.unwrap().tags.iter().any(|t| t == "auto-discovered"));
6833 }
6834
6835 #[test]
6836 fn explicit_user_row_is_not_pruned_by_auto_discovery_tag() {
6837 let _environment = crate::openrouter::test_environment_scope();
6838 let tmp = TempDir::new().unwrap();
6839 let models = tmp.path().join("models");
6840 let model_dir = models.join("Explicit-Custom-Llama");
6841 write_mlx_dir(&models, "Explicit-Custom-Llama", "llama");
6842
6843 let mut registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6844 let model_id = "local/explicit-custom-llama";
6845 let schema = registry.get(model_id).cloned().unwrap();
6846 assert!(schema.tags.iter().any(|tag| tag == "auto-discovered"));
6847
6848 registry.register_user_model(schema);
6849 std::fs::remove_dir_all(model_dir).unwrap();
6850 registry.prune_missing_on_disk_models();
6851
6852 assert!(
6853 registry.get(model_id).is_some(),
6854 "explicit registration provenance must outrank a user-controlled tag"
6855 );
6856 }
6857
6858 #[test]
6863 fn colliding_directory_does_not_claim_a_registered_row() {
6864 let tmp = TempDir::new().unwrap();
6865 let models = tmp.path().join("models");
6866 write_mlx_dir(&models, "Collide-User-Llama", "llama");
6867 write_mlx_dir(&models, "Collide-Project-Llama", "llama");
6868 let mut registry = UnifiedRegistry::new_empty(models.clone());
6869
6870 let mut user =
6871 synthesize_local_schema("Collide-User-Llama", &models.join("Collide-User-Llama"))
6872 .unwrap();
6873 user.name = "Configured-Elsewhere-User".into();
6874 let user_id = user.id.clone();
6875 registry.register_user_model(user);
6876
6877 let mut project = synthesize_local_schema(
6878 "Collide-Project-Llama",
6879 &models.join("Collide-Project-Llama"),
6880 )
6881 .unwrap();
6882 project.name = "Configured-Elsewhere-Project".into();
6883 let project_id = project.id.clone();
6884 assert!(registry.register_project_model(project));
6885
6886 registry.discover_on_disk_models();
6887 assert_eq!(
6888 registry.get(&user_id).map(|m| m.name.as_str()),
6889 Some("Configured-Elsewhere-User"),
6890 "discovery must not replace a registered row"
6891 );
6892
6893 std::fs::remove_dir_all(models.join("Collide-User-Llama")).unwrap();
6894 std::fs::remove_dir_all(models.join("Collide-Project-Llama")).unwrap();
6895 registry.prune_missing_on_disk_models();
6896
6897 assert!(
6898 registry.get(&user_id).is_some(),
6899 "removing a colliding directory must not erase a user row"
6900 );
6901 assert!(
6902 registry.get(&project_id).is_some(),
6903 "removing a colliding directory must not erase a project row"
6904 );
6905 }
6906
6907 #[test]
6908 fn signed_catalog_cannot_shadow_builtin_exact_id() {
6909 let _environment = crate::openrouter::test_environment_scope();
6914 let tmp = TempDir::new().unwrap();
6918 let models_dir = tmp.path().join("models");
6919
6920 let builtin = builtin_catalog();
6921 let mut overriding = builtin.first().expect("a built-in model").clone();
6922 let target_id = overriding.id.clone();
6923 overriding.name = "REPLACED-BY-CATALOG".into();
6924
6925 let (verified, public_key) = crate::catalog::signed_test_catalog(
6926 crate::catalog::CatalogDoc {
6927 revoked: Vec::new(),
6928 version: 1,
6929 models: vec![overriding],
6930 },
6931 51,
6932 );
6933 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
6934
6935 let reg = UnifiedRegistry::new_with_catalog_public_key(
6937 tmp.path().to_path_buf(),
6938 models_dir,
6939 Some(public_key.as_str()),
6940 );
6941 assert_eq!(
6942 reg.get(&target_id).map(|m| m.name.as_str()),
6943 Some(builtin.first().unwrap().name.as_str()),
6944 "a signed cache row must not replace a builtin exact id"
6945 );
6946 }
6947
6948 #[test]
6949 fn user_model_cannot_shadow_project_owned_exact_id() {
6950 let mut reg = test_registry();
6951 let original = builtin_catalog().first().expect("a builtin").clone();
6952 let mut forged = original.clone();
6953 forged.name = "USER-SHADOW".into();
6954
6955 reg.register_user_model(forged);
6956
6957 assert_eq!(
6958 reg.get(&original.id).map(|model| model.name.as_str()),
6959 Some(original.name.as_str()),
6960 "a user row must not replace a project-owned exact id"
6961 );
6962 }
6963
6964 #[test]
6965 fn legacy_unsigned_catalog_cache_cannot_replace_builtin() {
6966 let _environment = crate::openrouter::test_environment_scope();
6971 let tmp = TempDir::new().unwrap();
6972 let models_dir = tmp.path().join("models");
6973 let builtin = builtin_catalog();
6974 let original = builtin.first().expect("a built-in model");
6975 let mut forged = original.clone();
6976 forged.name = "FORGED-UNSIGNED-CATALOG".into();
6977 let path = crate::catalog::cache_path(tmp.path());
6978 std::fs::write(
6979 &path,
6980 serde_json::to_vec_pretty(&crate::catalog::CatalogDoc {
6981 revoked: Vec::new(),
6982 version: u64::MAX,
6983 models: vec![forged],
6984 })
6985 .unwrap(),
6986 )
6987 .unwrap();
6988
6989 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
6990 assert_eq!(
6991 reg.get(&original.id).map(|model| model.name.as_str()),
6992 Some(original.name.as_str()),
6993 "legacy unsigned cache JSON must fail closed and preserve the built-in"
6994 );
6995 }
6996
6997 #[test]
6998 fn tampered_signed_managed_row_preserves_builtin() {
6999 let _environment = crate::openrouter::test_environment_scope();
7004 let tmp = TempDir::new().unwrap();
7005 let models_dir = tmp.path().join("models");
7006 let original = builtin_catalog()
7007 .into_iter()
7008 .find(|model| model.id == "parslee/openrouter/frontier-general")
7009 .expect("managed frontier alias");
7010 let mut forged = original.clone();
7011 forged.name = "SIGNED-THEN-TAMPERED-MANAGED".into();
7012 let (verified, public_key) = crate::catalog::signed_test_catalog(
7013 crate::catalog::CatalogDoc {
7014 revoked: Vec::new(),
7015 version: 9,
7016 models: vec![forged],
7017 },
7018 52,
7019 );
7020 let path = crate::catalog::cache_path(tmp.path());
7021 crate::catalog::save_verified(&path, &verified).unwrap();
7022 let cache = std::fs::read_to_string(&path)
7023 .unwrap()
7024 .replace("SIGNED-THEN-TAMPERED-MANAGED", "ATTACKER-MUTATION");
7025 std::fs::write(&path, cache).unwrap();
7026
7027 let reg = UnifiedRegistry::new_with_catalog_public_key(
7028 tmp.path().to_path_buf(),
7029 models_dir,
7030 Some(public_key.as_str()),
7031 );
7032 assert_eq!(
7033 reg.get(&original.id).map(|model| model.name.as_str()),
7034 Some(original.name.as_str()),
7035 "a tampered same-id managed row must fail verification and preserve the builtin"
7036 );
7037 }
7038
7039 #[test]
7040 fn builtin_catalog_loads() {
7041 let reg = test_registry();
7042 let all = reg.list();
7043 assert_eq!(all.len(), builtin_catalog().len());
7044 }
7045
7046 #[test]
7047 fn subscription_billing_lookup_uses_exact_or_implicit_latest_ids() {
7048 let codex_ids: Vec<_> = builtin_catalog()
7049 .into_iter()
7050 .filter(|model| model.id.starts_with("openai-codex/"))
7051 .map(|model| model.id)
7052 .collect();
7053 assert!(!codex_ids.is_empty());
7054 for id in codex_ids {
7055 assert!(is_builtin_subscription_billed(&id), "{id}");
7056 if let Some(untagged) = id.strip_suffix(":latest") {
7057 assert!(is_builtin_subscription_billed(untagged), "{untagged}");
7058 }
7059 }
7060 assert!(!is_builtin_subscription_billed("parslee/reasoning"));
7061 assert!(!is_builtin_subscription_billed("openai/gpt-5.5-2026-04-23"));
7062 assert!(!is_builtin_subscription_billed("unknown/not-a-model"));
7063 }
7064
7065 #[test]
7066 fn shipped_supervised_vllm_models_use_the_managed_source_contract() {
7067 let managed = builtin_catalog()
7068 .into_iter()
7069 .filter(|model| model.id.starts_with("vllm-mlx/"))
7070 .collect::<Vec<_>>();
7071 assert_eq!(managed.len(), 15);
7072 assert!(managed.iter().all(ModelSchema::is_car_managed_vllm_mlx));
7073 assert!(managed.iter().all(ModelSchema::downloads_weights));
7074 }
7075
7076 #[test]
7089 fn mlx_vlm_models_reflect_runtime_availability() {
7090 let reg = test_registry();
7091 let mlx_vlm_models: Vec<&ModelSchema> = reg
7092 .list()
7093 .into_iter()
7094 .filter(|m| m.tags.iter().any(|t| t == "requires-mlx-vlm"))
7095 .collect();
7096 assert!(
7097 !mlx_vlm_models.is_empty(),
7098 "catalog should contain at least one model tagged \
7099 `requires-mlx-vlm` — otherwise this regression has \
7100 nothing to guard"
7101 );
7102
7103 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7104 let expected = crate::backend::mlx_vlm_cli::is_available();
7105 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7106 let expected = false;
7107
7108 for m in mlx_vlm_models {
7109 assert_eq!(
7110 m.available, expected,
7111 "model {} `available` field should reflect \
7112 mlx_vlm CLI presence (expected {expected}, got {})",
7113 m.id, m.available
7114 );
7115 }
7116 }
7117
7118 #[test]
7130 fn mlx_models_unavailable_on_non_mlx_targets() {
7131 let reg = test_registry();
7132 let mlx_models: Vec<&ModelSchema> = reg
7133 .list()
7134 .into_iter()
7135 .filter(|m| {
7136 m.is_mlx()
7137 && !m.tags.iter().any(|t| t == "requires-mlx-vlm")
7142 && !m.tags.contains(&"speech".to_string())
7143 })
7144 .collect();
7145 assert!(
7146 !mlx_models.is_empty(),
7147 "catalog should contain at least one plain MLX model — \
7148 otherwise this F1 regression guard has nothing to guard"
7149 );
7150
7151 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7152 {
7153 let any_available = mlx_models.iter().any(|m| m.available);
7157 assert!(
7158 any_available,
7159 "on macOS arm64 with MLX enabled, at least one plain MLX \
7160 model with hf_repo should be available — none were"
7161 );
7162 }
7163 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7164 {
7165 for m in &mlx_models {
7168 assert!(
7169 !m.available,
7170 "MLX model {} is marked available on a non-MLX target — \
7171 the adaptive router will add it to fallback chains \
7172 and dispatch will fail (Parslee-ai/car#231 §7.1)",
7173 m.id
7174 );
7175 }
7176 }
7177 }
7178
7179 fn is_builtin_model_id_form(id: &str) -> bool {
7184 fn part(s: &str) -> bool {
7185 let mut chars = s.chars();
7186 chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
7187 && chars.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
7188 }
7189 let (body, tag) = match id.split_once(':') {
7190 Some((body, tag)) => (body, Some(tag)),
7191 None => (id, None),
7192 };
7193 let Some((provider, name)) = body.split_once('/') else {
7194 return false;
7195 };
7196 part(provider) && part(name) && tag.is_none_or(part)
7197 }
7198
7199 #[test]
7200 fn builtin_model_ids_follow_the_resolved_model_id_contract() {
7201 for good in [
7202 "anthropic/claude-opus-4-8",
7203 "openai/gpt-5.5-2026-04-23",
7204 "qwen/qwen3-4b:q4_k_m",
7205 ] {
7206 assert!(is_builtin_model_id_form(good), "{good} should pass");
7207 }
7208 for bad in [
7209 "",
7210 "gpt-5.5",
7211 "/name",
7212 "openai/",
7213 "-openai/x",
7214 "openai/-x",
7215 "open ai/x",
7216 "openai/x y",
7217 "openai/é",
7218 "parslee/openrouter/open-fast",
7219 "openai/x:",
7220 "openai/x:-a",
7221 "openai/x:a b",
7222 "openai/x:a:b",
7223 "openai/x:a/b",
7224 ] {
7225 assert!(!is_builtin_model_id_form(bad), "{bad:?} should fail");
7226 }
7227 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7228 let off: Vec<&str> = catalog
7229 .iter()
7230 .map(|m| m.id.as_str())
7231 .filter(|id| !is_builtin_model_id_form(id))
7232 .collect();
7233 assert!(off.is_empty(), "built-in ids outside the contract: {off:?}");
7234 }
7235
7236 #[test]
7237 fn builtin_catalog_json_parses() {
7238 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON)
7239 .expect("builtin_catalog.json must be valid ModelSchema array");
7240 assert!(
7241 !catalog.is_empty(),
7242 "embedded catalog has no entries — that's almost certainly wrong"
7243 );
7244
7245 let mut seen = std::collections::HashSet::new();
7246 for entry in &catalog {
7247 assert!(
7248 seen.insert(entry.id.clone()),
7249 "duplicate id in builtin_catalog.json: {}",
7250 entry.id
7251 );
7252 }
7253 }
7254
7255 #[test]
7256 fn codex_subscription_row_has_pinnable_tool_proposal_identity() {
7257 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7258 let row = catalog
7259 .iter()
7260 .find(|model| model.id == "openai/gpt-5.6-sol:high")
7261 .expect("catalog publishes the Codex subscription identity");
7262 assert_eq!(row.name, "gpt-5.6-sol:high");
7263 assert!(matches!(
7264 &row.source,
7265 ModelSource::CodexCli { model } if model == "gpt-5.6-sol:high"
7266 ));
7267 assert!(row.has_capability(ModelCapability::Generate));
7268 assert!(row.has_capability(ModelCapability::Reasoning));
7269 assert!(row.has_capability(ModelCapability::ToolUse));
7270 assert!(row.has_capability(ModelCapability::MultiToolCall));
7271 assert!(row.has_capability(ModelCapability::Vision));
7273 assert!(!row.tags.iter().any(|tag| tag == "text-only"));
7274 assert_eq!(row.supported_params, vec![GenerateParam::MaxTokens]);
7275 assert!(!row.downloads_weights());
7276 }
7277
7278 #[test]
7279 fn codex_subscription_rows_publish_sol_6_1_without_invented_measurements() {
7280 let catalog: serde_json::Value = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7281 let rows = catalog.as_array().unwrap();
7282 let old = rows
7283 .iter()
7284 .find(|r| r["id"] == "openai-codex/gpt-5.6-sol:high")
7285 .unwrap();
7286 for id in [
7287 "openai-codex/gpt-6.1-sol:high",
7288 "openai-codex/gpt-6.1-sol:medium",
7289 ] {
7290 let row = rows.iter().find(|r| r["id"] == id).unwrap();
7291 assert_eq!(row["family"], "gpt-6.1");
7292 assert_eq!(row["context_length"], 500000);
7293 assert_eq!(row["capabilities"], old["capabilities"]);
7294 assert_eq!(row["tags"], old["tags"]);
7295 assert_eq!(row["source"], old["source"]);
7296 assert_eq!(row["source"]["auth"]["type"], "chatgpt_subscription");
7297 assert_eq!(row["source"]["protocol"]["chat_path"], "/codex/responses");
7298 assert!(row.get("public_benchmarks").is_none());
7299 }
7300 }
7301
7302 #[test]
7303 fn codex_subscription_rows_publish_the_gpt_5_6_family() {
7304 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7305 assert_eq!(
7306 catalog
7307 .iter()
7308 .filter(|model| model.provider == "openai-codex")
7309 .count(),
7310 8,
7311 "the explicit checks below must cover every openai-codex row"
7312 );
7313 for id in [
7314 "openai-codex/gpt-6.1-sol:high",
7315 "openai-codex/gpt-6.1-sol:medium",
7316 "openai-codex/gpt-5.6-sol:latest",
7317 "openai-codex/gpt-5.6-sol:high",
7318 "openai-codex/gpt-5.6-luna:latest",
7319 "openai-codex/gpt-5.6-luna:high",
7320 "openai-codex/gpt-5.6-terra:latest",
7321 "openai-codex/gpt-5.6-terra:high",
7322 ] {
7323 let row = catalog
7324 .iter()
7325 .find(|model| model.id == id)
7326 .unwrap_or_else(|| panic!("missing {id}"));
7327 assert_eq!(row.provider, "openai-codex", "{id}");
7328 assert!(
7329 matches!(&row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::ChatGptSubscription {}, .. } if provider == "openai-codex"),
7330 "{id}"
7331 );
7332 assert!(row.is_subscription_billed(), "{id}");
7333 assert!(
7334 row.cost.input_per_mtok.is_none() && row.cost.output_per_mtok.is_none(),
7335 "{id} must carry no dollar price"
7336 );
7337 assert!(row.tags.iter().any(|tag| tag == "subscription"), "{id}");
7338 assert!(
7339 row.cost
7340 .estimated_usd_bounded(Some(1_000), 1_000, 1_000, 0, 0)
7341 .is_none(),
7342 "{id} must not produce an approximate dollar cost"
7343 );
7344 let mut accidentally_priced = row.clone();
7345 accidentally_priced.cost.input_per_mtok = Some(1.0);
7346 accidentally_priced.cost.output_per_mtok = Some(1.0);
7347 assert!(
7348 crate::scoreboard_price_model(&accidentally_priced).is_none(),
7349 "{id} must stay out of the dollar scoreboard"
7350 );
7351 assert!(row.has_capability(ModelCapability::ToolUse), "{id}");
7352 assert!(row.has_capability(ModelCapability::Vision), "{id}");
7354 assert_eq!(row.name.ends_with(":high"), id.ends_with(":high"), "{id}");
7355 }
7356 let cli = catalog
7357 .iter()
7358 .find(|model| model.id == "openai/gpt-5.6-sol:high")
7359 .unwrap();
7360 assert!(
7361 matches!(&cli.source, ModelSource::CodexCli { .. }),
7362 "the Codex CLI row stays as it was"
7363 );
7364 assert!(!cli.is_subscription_billed());
7365 let api = catalog
7366 .iter()
7367 .find(|model| model.id == "openai/gpt-5.6-sol:latest")
7368 .unwrap();
7369 assert!(
7370 matches!(&api.source, ModelSource::RemoteApi { .. }),
7371 "the API-key row stays as it was"
7372 );
7373 assert!(!api.is_subscription_billed());
7374 let parslee = catalog
7375 .iter()
7376 .find(|model| model.id == "parslee/fast")
7377 .expect("catalog publishes a Parslee row");
7378 assert!(!parslee.is_subscription_billed());
7379 }
7380
7381 #[test]
7387 fn a_codex_lookup_error_clears_the_shared_hint_and_is_recorded_as_unknown() {
7388 let _environment = crate::openrouter::test_environment_scope();
7389 let tmp = TempDir::new().unwrap();
7390 let mut registry = UnifiedRegistry::new_with_state_root(
7391 tmp.path().to_path_buf(),
7392 tmp.path().join("models"),
7393 );
7394 let codex_id = "openai-codex/gpt-5.6-sol:latest";
7395
7396 remember_codex_sign_in(true);
7397 registry.record_codex_lookup(Err("the secret store is locked".into()));
7398
7399 assert!(
7400 !passive_codex_oauth_available(),
7401 "a lookup error clears the shared hint, as on main"
7402 );
7403 registry.refresh_availability();
7404 let row = registry.get(codex_id).expect("builtin Codex row").clone();
7405 assert!(
7406 !row.available,
7407 "callers reading only `available` see it unavailable"
7408 );
7409 registry.record_codex_lookup(Err("the secret store is locked".into()));
7410 assert!(
7411 registry.row_credential_state_unknown(&row),
7412 "the planner's unknown set holds the row"
7413 );
7414
7415 registry.record_codex_lookup(Ok(true));
7416 assert!(passive_codex_oauth_available());
7417 remember_codex_sign_in(false);
7418 }
7419
7420 #[test]
7421 fn codex_rows_unavailable_without_sign_in_carry_the_sign_in_reason() {
7422 let _environment = crate::openrouter::test_environment_scope();
7423 remember_codex_sign_in(false);
7424
7425 let tmp = TempDir::new().unwrap();
7426 let mut registry = UnifiedRegistry::new_with_state_root(
7427 tmp.path().to_path_buf(),
7428 tmp.path().join("models"),
7429 );
7430 let expected_ids = [
7431 "openai-codex/gpt-6.1-sol:high",
7432 "openai-codex/gpt-6.1-sol:medium",
7433 "openai-codex/gpt-5.6-sol:latest",
7434 "openai-codex/gpt-5.6-sol:high",
7435 "openai-codex/gpt-5.6-luna:latest",
7436 "openai-codex/gpt-5.6-luna:high",
7437 "openai-codex/gpt-5.6-terra:latest",
7438 "openai-codex/gpt-5.6-terra:high",
7439 ];
7440
7441 registry
7443 .models
7444 .get_mut("openai/gpt-6.1-sol:latest")
7445 .unwrap()
7446 .available = true;
7447 for id in expected_ids {
7448 let info = ModelInfo::from(
7449 registry
7450 .get(id)
7451 .unwrap_or_else(|| panic!("missing builtin Codex row {id}")),
7452 );
7453 assert!(!info.available, "{id} must be unavailable while signed out");
7454 let rows: Vec<_> = registry.all().cloned().collect();
7455 let resolved = find_model_row(&rows, id).expect("explicit subscription resolution");
7456 assert_eq!(
7457 resolved.id, id,
7458 "signed-out subscription must not fall back to an API row"
7459 );
7460 assert!(!resolved.available);
7461 let reason = model_unavailable_reason(&info).expect("signed-out Codex reason");
7462 assert_eq!(reason, crate::schema::OPENAI_CODEX_SIGN_IN_HINT);
7463 assert_eq!(
7464 serde_json::to_value(&info).unwrap()["unavailable_reason"],
7465 reason
7466 );
7467 assert!(reason.contains("car auth login --provider openai-codex"));
7468 assert!(reason.contains("Settings → AI accounts"));
7469 }
7470
7471 remember_codex_sign_in(true);
7472 registry.refresh_availability();
7473
7474 for id in expected_ids {
7475 let info = ModelInfo::from(registry.get(id).unwrap());
7476 assert!(info.available, "{id} must be available while signed in");
7477 assert_eq!(model_unavailable_reason(&info), None, "{id}");
7478 assert!(serde_json::to_value(&info).unwrap()["unavailable_reason"].is_null());
7479 }
7480
7481 remember_codex_sign_in(false);
7482 }
7483
7484 #[tokio::test]
7485 async fn signed_out_subscription_generation_never_dispatches_api_fallback() {
7486 let _slot = crate::runner::test_slot_lock().lock().await;
7487 let _environment = crate::openrouter::test_environment_scope_async().await;
7488 remember_codex_sign_in(false);
7489 let root = TempDir::new().unwrap();
7490 struct Restore;
7491 impl Drop for Restore {
7492 fn drop(&mut self) {
7493 crate::set_inference_runner(None);
7494 }
7495 }
7496 struct ApiProbe(std::sync::Arc<std::sync::atomic::AtomicUsize>);
7497 #[async_trait::async_trait]
7498 impl crate::InferenceRunner for ApiProbe {
7499 async fn run(
7500 &self,
7501 _: crate::GenerateRequest,
7502 _: crate::EventEmitter,
7503 ) -> Result<crate::RunnerResult, crate::RunnerError> {
7504 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7505 Ok(crate::RunnerResult {
7506 text: "forbidden fallback".into(),
7507 tool_calls: vec![],
7508 })
7509 }
7510 }
7511 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
7512 let _restore = Restore;
7513 crate::set_inference_runner(Some(std::sync::Arc::new(ApiProbe(calls.clone()))));
7514 let mut engine = crate::InferenceEngine::new(crate::InferenceConfig {
7515 state_root: root.path().join("state"),
7516 models_dir: root.path().join("models"),
7517 ..Default::default()
7518 });
7519 let mut alternative = engine
7520 .registered_schema("openai/gpt-6.1-sol:latest")
7521 .unwrap();
7522 alternative.source = ModelSource::Delegated {
7523 hint: Some("instrumented-api-alternative".into()),
7524 };
7525 alternative.available = true;
7526 engine.register_model(alternative);
7527 for id in [
7528 "openai-codex/gpt-6.1-sol:high",
7529 "openai-codex/gpt-6.1-sol:medium",
7530 ] {
7531 assert!(!engine.registered_schema(id).unwrap().available);
7532 let mut request = crate::GenerateRequest {
7533 prompt: "signed-out probe".into(),
7534 model: Some(id.into()),
7535 ..Default::default()
7536 };
7537 request.params.strict_model = true;
7538 let result = engine.generate_tracked(request).await;
7539 assert!(result.is_err(), "signed-out request unexpectedly succeeded");
7540 assert_eq!(
7541 calls.load(std::sync::atomic::Ordering::SeqCst),
7542 0,
7543 "API fallback executed"
7544 );
7545 }
7546 }
7547
7548 #[test]
7549 fn work_context_availability_is_independent_of_other_org_rejection() {
7550 let _environment = crate::openrouter::test_environment_scope();
7551 crate::parslee_credential::clear_credential_rejected();
7552 let tmp = TempDir::new().unwrap();
7553 let mut parslee = UnifiedRegistry::new_with_state_root(
7554 tmp.path().to_path_buf(),
7555 tmp.path().join("models"),
7556 );
7557 let mut flyexclusive = parslee.clone();
7558 crate::parslee_credential::note_credential_rejected();
7559 parslee.refresh_work_context_availability("https://authority.example", true);
7560 flyexclusive.refresh_work_context_availability("https://authority.example", false);
7561 let scoped_rows: Vec<_> = parslee.all().filter(|row| matches!(
7562 &row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::OAuth2Pkce { .. }, .. }
7563 if provider == "parslee"
7564 )).collect();
7565 assert!(!scoped_rows.is_empty());
7566 for row in scoped_rows {
7567 assert!(row.available);
7568 assert!(!flyexclusive.get(&row.id).unwrap().available);
7569 }
7570 assert!(
7571 crate::parslee_credential::credential_rejected(),
7572 "scoped routing must not clear another context's observation"
7573 );
7574 crate::parslee_credential::clear_credential_rejected();
7575 }
7576
7577 #[test]
7578 fn codex_availability_leaves_openai_and_parslee_rows_alone() {
7579 let _environment = crate::openrouter::test_environment_scope();
7580 remember_codex_sign_in(false);
7581
7582 let tmp = TempDir::new().unwrap();
7583 let mut registry = UnifiedRegistry::new_with_state_root(
7584 tmp.path().to_path_buf(),
7585 tmp.path().join("models"),
7586 );
7587 registry.refresh_availability();
7588
7589 let unaffected_ids: Vec<String> = registry
7590 .list()
7591 .into_iter()
7592 .filter(|row| {
7593 matches!(
7594 row.id.as_str(),
7595 "openai/gpt-5.6-sol:latest" | "openai/gpt-5.6-sol:high"
7596 ) || row.id.starts_with("parslee/")
7597 })
7598 .map(|row| row.id.clone())
7599 .collect();
7600 assert!(unaffected_ids
7601 .iter()
7602 .any(|id| id == "openai/gpt-5.6-sol:latest"));
7603 assert!(unaffected_ids
7604 .iter()
7605 .any(|id| id == "openai/gpt-5.6-sol:high"));
7606 assert!(
7607 unaffected_ids.iter().any(|id| id.starts_with("parslee/")),
7608 "builtin catalog must contain Parslee rows"
7609 );
7610 let signed_out: std::collections::HashMap<String, bool> = unaffected_ids
7611 .iter()
7612 .map(|id| (id.clone(), registry.get(id).unwrap().available))
7613 .collect();
7614
7615 remember_codex_sign_in(true);
7616 registry.refresh_availability();
7617
7618 for id in unaffected_ids {
7619 assert_eq!(
7620 registry.get(&id).unwrap().available,
7621 signed_out[&id],
7622 "Codex sign-in must not change {id} availability"
7623 );
7624 }
7625
7626 remember_codex_sign_in(false);
7627 }
7628
7629 #[test]
7630 fn builtin_catalog_names_are_unique_for_codex_rows() {
7631 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7632 for row in catalog.iter().filter(|row| row.provider == "openai-codex") {
7633 let occurrences = catalog
7634 .iter()
7635 .filter(|candidate| candidate.name == row.name)
7636 .count();
7637 assert_eq!(
7638 occurrences, 1,
7639 "openai-codex name must be unique: {}",
7640 row.name
7641 );
7642 }
7643 }
7644
7645 #[test]
7668 fn apple_foundation_row_claims_single_tool_call_and_the_real_context_window() {
7669 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7670 let row = catalog
7671 .iter()
7672 .find(|model| model.id == "apple/foundation:default")
7673 .expect("catalog publishes the Apple FoundationModels row");
7674 assert!(matches!(
7675 &row.source,
7676 ModelSource::AppleFoundationModels { .. }
7677 ));
7678 assert!(row.has_capability(ModelCapability::Generate));
7679 assert!(
7680 row.has_capability(ModelCapability::ToolUse),
7681 "generate_with_tools exists, so the router must be able to pick this row for tool routes"
7682 );
7683 assert!(
7684 !row.has_capability(ModelCapability::MultiToolCall),
7685 "the capture sentinel ends the turn on the first tool call"
7686 );
7687 assert!(!row.has_capability(ModelCapability::Vision));
7688 assert_eq!(
7689 row.context_length, 4096,
7690 "SystemLanguageModel's context window is 4096 tokens"
7691 );
7692 assert!(!row.downloads_weights());
7693 }
7694
7695 #[test]
7708 fn apple_foundation_multi_tool_call_tracks_what_the_host_can_actually_do() {
7709 #[cfg(any(
7710 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7711 all(target_os = "ios", target_arch = "aarch64")
7712 ))]
7713 {
7714 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7715 let declared = catalog
7716 .iter()
7717 .find(|m| m.id == "apple/foundation:default")
7718 .expect("catalog publishes the Apple FoundationModels row");
7719 assert!(
7720 !declared.has_capability(ModelCapability::MultiToolCall),
7721 "the catalog literal must stay conservative; the host decides"
7722 );
7723
7724 let models_dir = tempfile::tempdir().unwrap();
7726 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
7727 let live = registry
7733 .models
7734 .get("apple/foundation:default")
7735 .expect("the builtin catalog row must survive into the live registry");
7736 let host_can = crate::backend::foundation_models::supports_parallel_tool_calls();
7737 assert_eq!(
7738 live.capabilities.contains(&ModelCapability::MultiToolCall),
7739 host_can,
7740 "live row must claim multi_tool_call iff the host supports it"
7741 );
7742 }
7743 }
7744
7745 #[test]
7757 fn apple_foundation_claims_vision_only_where_images_can_be_served() {
7758 #[cfg(any(
7759 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7760 all(target_os = "ios", target_arch = "aarch64")
7761 ))]
7762 {
7763 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7764 let declared = catalog
7765 .iter()
7766 .find(|m| m.id == "apple/foundation:default")
7767 .expect("catalog publishes the Apple FoundationModels row");
7768 assert!(
7769 !declared.has_capability(ModelCapability::Vision),
7770 "the catalog literal must stay conservative; the device decides"
7771 );
7772
7773 let models_dir = tempfile::tempdir().unwrap();
7774 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
7775 let live = registry
7776 .models
7777 .get("apple/foundation:default")
7778 .expect("the builtin catalog row must survive into the live registry");
7779
7780 assert_eq!(
7781 live.capabilities.contains(&ModelCapability::Vision),
7782 crate::backend::foundation_models::supports_vision(),
7783 "the live row must claim vision iff this device's model accepts images"
7784 );
7785 }
7786 }
7787
7788 #[test]
7805 fn a_host_without_parallel_support_gets_exactly_the_catalog_capabilities() {
7806 #[cfg(any(
7807 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7808 all(target_os = "ios", target_arch = "aarch64")
7809 ))]
7810 {
7811 if crate::backend::foundation_models::supports_parallel_tool_calls() {
7812 return;
7813 }
7814 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7815 let declared = catalog
7816 .iter()
7817 .find(|m| m.id == "apple/foundation:default")
7818 .expect("catalog publishes the Apple FoundationModels row");
7819
7820 let models_dir = tempfile::tempdir().unwrap();
7821 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
7822 let live = registry
7823 .models
7824 .get("apple/foundation:default")
7825 .expect("the builtin catalog row must survive into the live registry");
7826
7827 assert_eq!(
7828 live.capabilities, declared.capabilities,
7829 "a host without the macOS 27 behaviour must advertise exactly what \
7830 the catalog declares — nothing added, nothing removed"
7831 );
7832 }
7833 }
7834
7835 #[test]
7845 fn apple_foundation_context_size_is_either_the_os_answer_or_none() {
7846 #[cfg(any(
7847 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7848 all(target_os = "ios", target_arch = "aarch64")
7849 ))]
7850 {
7851 if let Some(window) = crate::backend::foundation_models::context_size() {
7852 assert!(
7853 window >= 512,
7854 "a context window the framework reports must be usable, got {window}"
7855 );
7856 }
7857 }
7858
7859 #[cfg(not(any(
7862 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7863 all(target_os = "ios", target_arch = "aarch64")
7864 )))]
7865 {
7866 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7867 let row = catalog
7868 .iter()
7869 .find(|model| model.id == "apple/foundation:default")
7870 .expect("catalog publishes the Apple FoundationModels row");
7871 assert_eq!(row.context_length, 4096);
7872 }
7873 }
7874
7875 #[test]
7882 fn in_process_qwen3_models_declare_tool_use() {
7883 use crate::schema::ModelSource;
7884 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7885 let tool_sizes = ["qwen3-4b", "qwen3-8b", "qwen3-30b-a3b"];
7888 let mut checked = 0;
7889 for entry in &catalog {
7890 let in_process = matches!(
7891 entry.source,
7892 ModelSource::Mlx { .. } | ModelSource::Local { .. }
7893 );
7894 if !in_process || !tool_sizes.iter().any(|s| entry.id.contains(s)) {
7895 continue;
7896 }
7897 assert!(
7898 entry.capabilities.contains(&ModelCapability::ToolUse),
7899 "in-process Qwen3 model {} should advertise ToolUse — the local \
7900 generate path renders/parses tool calls",
7901 entry.id
7902 );
7903 checked += 1;
7904 }
7905 assert_eq!(
7906 checked, 6,
7907 "expected 6 in-process tool-capable Qwen3 entries (3 mlx + 3 gguf)"
7908 );
7909 }
7910
7911 #[test]
7912 fn public_benchmarks_round_trip_through_model_info() {
7913 use crate::schema::BenchmarkScore;
7914 let mut reg = test_registry();
7915 let mut schema = reg
7916 .find_by_name("Qwen3-4B")
7917 .expect("catalog has Qwen3-4B")
7918 .clone();
7919 schema.id = "test/qwen3-4b-with-bench".into();
7920 schema.public_benchmarks = vec![
7921 BenchmarkScore {
7922 name: "MMLU-Pro".into(),
7923 score: 0.482,
7924 harness: Some("5-shot CoT".into()),
7925 source_url: Some("https://example.invalid/qwen3-4b-card".into()),
7926 measured_at: Some("2025-08-12".into()),
7927 runs: None,
7928 spread: None,
7929 },
7930 BenchmarkScore {
7931 name: "HumanEval".into(),
7932 score: 0.713,
7933 harness: Some("pass@1".into()),
7934 source_url: None,
7935 measured_at: None,
7936 runs: None,
7937 spread: None,
7938 },
7939 ];
7940 reg.register(schema);
7941
7942 let stored = reg
7943 .get("test/qwen3-4b-with-bench")
7944 .expect("registered model is retrievable");
7945 let info = ModelInfo::from(stored);
7946 assert_eq!(info.public_benchmarks.len(), 2);
7947
7948 let json = serde_json::to_string(&info).unwrap();
7950 assert!(json.contains("\"public_benchmarks\""));
7951 assert!(json.contains("\"MMLU-Pro\""));
7952 assert!(json.contains("\"5-shot CoT\""));
7953
7954 let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
7956 assert_eq!(decoded.public_benchmarks.len(), 2);
7957 assert_eq!(decoded.public_benchmarks[0].name, "MMLU-Pro");
7958 assert_eq!(decoded.public_benchmarks[1].name, "HumanEval");
7959 }
7960
7961 #[test]
7962 fn public_benchmarks_default_to_empty_when_absent_in_json() {
7963 let legacy_json = r#"{
7966 "id": "legacy/test:1",
7967 "name": "Legacy Test",
7968 "provider": "test",
7969 "family": "test",
7970 "version": "",
7971 "capabilities": ["generate"],
7972 "context_length": 4096,
7973 "param_count": "1B",
7974 "quantization": null,
7975 "performance": {},
7976 "cost": {},
7977 "source": { "type": "ollama", "model_tag": "legacy:1" },
7978 "tags": [],
7979 "supported_params": []
7980 }"#;
7981 let schema: ModelSchema = serde_json::from_str(legacy_json).unwrap();
7982 assert!(schema.public_benchmarks.is_empty());
7983 }
7984
7985 #[test]
7986 fn find_by_name() {
7987 let reg = test_registry();
7988 let m = reg.find_by_name("Qwen3-4B").unwrap();
7989 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7990 assert_eq!(m.id, "mlx/qwen3-4b:4bit");
7991 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7992 assert_eq!(m.id, "qwen/qwen3-4b:q4_k_m");
7993 assert!(m.has_capability(ModelCapability::Code));
7994 }
7995
7996 #[test]
8000 fn an_untagged_id_names_its_latest_row() {
8001 let reg = test_registry();
8002 let resolved = |model: &str| {
8003 reg.get(model)
8004 .or_else(|| reg.find_by_name(model))
8005 .map(|m| m.id.clone())
8006 };
8007 assert_eq!(
8008 resolved("anthropic/claude-haiku-4-5").as_deref(),
8009 Some("anthropic/claude-haiku-4-5:latest")
8010 );
8011 assert_eq!(
8014 resolved("anthropic/claude-opus-4-8").as_deref(),
8015 Some("anthropic/claude-opus-4-8")
8016 );
8017 assert_eq!(resolved("anthropic/claude-haiku-4-5:high"), None);
8018 assert_eq!(resolved("anthropic/claude-haiku-9"), None);
8019 assert_eq!(
8022 reg.find_by_name("anthropic/claude-opus-4-8")
8023 .map(|m| m.id.as_str()),
8024 None
8025 );
8026
8027 let rows = builtin_catalog();
8029 let row = |model: &str| find_model_row(&rows, model).map(|m| m.id.clone());
8030 assert_eq!(
8031 row("anthropic/claude-haiku-4-5").as_deref(),
8032 Some("anthropic/claude-haiku-4-5:latest")
8033 );
8034 assert_eq!(
8035 row("anthropic/claude-opus-4-8").as_deref(),
8036 Some("anthropic/claude-opus-4-8")
8037 );
8038 assert_eq!(
8039 row("claude-haiku-4-5").as_deref(),
8040 Some("anthropic/claude-haiku-4-5:latest")
8041 );
8042 assert_eq!(row("anthropic/claude-haiku-4-5:high"), None);
8043 assert_eq!(row("anthropic/claude-haiku-9"), None);
8044 }
8045
8046 #[test]
8047 fn query_by_capability() {
8048 let reg = test_registry();
8049 let embed_models = reg.query_by_capability(ModelCapability::Embed);
8050 assert_eq!(embed_models.len(), 2);
8051 assert!(embed_models
8052 .iter()
8053 .any(|model| model.name == "Qwen3-Embedding-0.6B"));
8054 assert!(embed_models
8055 .iter()
8056 .any(|model| model.name == "Qwen3-Embedding-0.6B-MLX"));
8057 }
8058
8059 #[test]
8060 fn query_with_filter() {
8061 let reg = test_registry();
8062 let code_small = reg.query(&ModelFilter {
8063 capabilities: vec![ModelCapability::Code],
8064 max_size_mb: Some(3000),
8065 local_only: true,
8066 ..Default::default()
8067 });
8068 assert_eq!(code_small.len(), 7);
8071 }
8072
8073 #[test]
8074 fn register_remote() {
8075 let mut reg = test_registry();
8076 let initial_len = reg.list().len();
8077 let initial_reasoning_len = reg
8078 .query(&ModelFilter {
8079 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
8080 ..Default::default()
8081 })
8082 .len();
8083 let remote = ModelSchema {
8084 id: "anthropic/claude-sonnet-4-6:latest".into(),
8085 name: "Claude Sonnet 4.6".into(),
8086 provider: "anthropic".into(),
8087 family: "claude-4".into(),
8088 version: "latest".into(),
8089 capabilities: vec![
8090 ModelCapability::Generate,
8091 ModelCapability::Code,
8092 ModelCapability::Reasoning,
8093 ModelCapability::ToolUse,
8094 ],
8095 context_length: 200000,
8096 max_output_tokens: None,
8097 param_count: String::new(),
8098 quantization: None,
8099 performance: PerformanceEnvelope {
8100 latency_p50_ms: Some(2000),
8101 ..Default::default()
8102 },
8103 cost: CostModel {
8104 input_per_mtok: Some(3.0),
8105 output_per_mtok: Some(15.0),
8106 ..Default::default()
8107 },
8108 source: ModelSource::RemoteApi {
8109 endpoint: "https://api.anthropic.com/v1/messages".into(),
8110 api_key_env: "ANTHROPIC_API_KEY".into(),
8111 api_key_envs: vec![],
8112 api_version: Some("2023-06-01".into()),
8113 protocol: ApiProtocol::Anthropic,
8114 },
8115 tags: vec![],
8116 supported_params: vec![],
8117 public_benchmarks: vec![],
8118 trust_tier: crate::schema::TrustTier::Curated,
8119 deprecated: false,
8120 available: false,
8121 weights_ready: false,
8122 };
8123
8124 reg.register(remote);
8125 assert_eq!(reg.list().len(), initial_len);
8127
8128 let reasoning = reg.query(&ModelFilter {
8129 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
8130 ..Default::default()
8131 });
8132 assert_eq!(reasoning.len(), initial_reasoning_len);
8134 }
8135
8136 #[test]
8137 fn unregister() {
8138 let mut reg = test_registry();
8139 let initial_len = reg.list().len();
8140 let removed = reg.unregister("qwen/qwen3-0.6b:q8_0");
8141 assert!(removed.is_some());
8142 assert_eq!(reg.list().len(), initial_len - 1);
8143 }
8144
8145 #[test]
8146 fn speech_models_are_curated() {
8147 let reg = test_registry();
8148 let stt = reg.query_by_capability(ModelCapability::SpeechToText);
8149 let tts = reg.query_by_capability(ModelCapability::TextToSpeech);
8150 assert_eq!(stt.len(), 3);
8152 assert_eq!(tts.len(), 6);
8155 let whisper = stt
8158 .iter()
8159 .find(|m| m.name == "Whisper-large-v3-turbo-q5_0")
8160 .expect("whisper STT model should be curated");
8161 assert!(whisper.is_local());
8162 assert!(matches!(
8163 whisper.source,
8164 crate::schema::ModelSource::WhisperCpp { .. }
8165 ));
8166 }
8167
8168 #[test]
8169 fn qwen_8b_variants_keep_tool_use_consistent() {
8170 let reg = test_registry();
8175 for name in ["Qwen3-8B", "Qwen3-8B-MLX"] {
8176 let model = reg.find_by_name(name).expect("model should exist");
8177 assert!(model.has_capability(ModelCapability::ToolUse));
8178 assert!(model.has_capability(ModelCapability::MultiToolCall));
8179 }
8180 }
8181
8182 #[test]
8183 fn mac_name_resolution_prefers_mlx_siblings() {
8184 #[allow(unused_variables)]
8187 let reg = test_registry();
8188 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
8189 {
8190 assert_eq!(
8191 reg.find_by_name("Qwen3-0.6B").unwrap().id,
8192 "mlx/qwen3-0.6b:6bit"
8193 );
8194 assert_eq!(
8195 reg.find_by_name("Qwen3-1.7B").unwrap().id,
8196 "mlx/qwen3-1.7b:3bit"
8197 );
8198 assert_eq!(
8199 reg.find_by_name("Qwen3-Embedding-0.6B").unwrap().id,
8200 "mlx/qwen3-embedding-0.6b:mxfp8"
8201 );
8202 }
8203 }
8204
8205 #[test]
8206 fn remote_multimodal_models_are_curated_as_vision_capable() {
8207 let reg = test_registry();
8208 for name in [
8209 "claude-opus-4-7",
8210 "claude-opus-4-6",
8211 "claude-sonnet-4-6",
8212 "claude-haiku-4-5",
8213 "gpt-5.4",
8214 "gpt-5.4-mini",
8215 "o3",
8216 "o4-mini",
8217 "gpt-4.1-mini",
8218 "gemini-2.5-pro",
8219 "gemini-2.5-flash",
8220 ] {
8221 let model = reg.find_by_name(name).expect("model should exist");
8222 assert!(
8223 model.has_capability(ModelCapability::Vision),
8224 "{name} should be curated as vision-capable"
8225 );
8226 }
8227 }
8228
8229 #[test]
8230 fn qwen25vl_entries_are_replaced_by_qwen3vl_in_builtin_catalog() {
8231 let reg = test_registry();
8232
8233 let stale_ids = [
8234 "mlx/qwen2.5-vl-3b:4bit",
8236 "mlx/qwen2.5-vl-7b:4bit",
8237 "mlx-vlm/qwen2.5-vl-3b:4bit",
8240 "mlx-vlm/qwen2.5-vl-7b:4bit",
8241 "vllm-mlx/qwen2.5-vl-3b:4bit",
8243 ];
8244 for id in stale_ids {
8245 assert!(
8246 reg.get(id).is_none(),
8247 "{id} is superseded by Qwen3-VL; the catalog must not advertise it"
8248 );
8249 }
8250
8251 let vision_ids: Vec<&str> = reg
8252 .query_by_capability(ModelCapability::Vision)
8253 .into_iter()
8254 .map(|model| model.id.as_str())
8255 .collect();
8256 for stale in stale_ids {
8257 assert!(
8258 !vision_ids.contains(&stale),
8259 "{stale} must not be reachable through the Vision capability index"
8260 );
8261 }
8262 assert!(
8263 vision_ids.contains(&"mlx-vlm/qwen3-vl-2b:bf16"),
8264 "Qwen3-VL is the supported local VL family and must route as Vision"
8265 );
8266 }
8267
8268 #[test]
8269 fn gemini_models_are_curated_for_multimodal_tool_use() {
8270 let reg = test_registry();
8271 for name in ["gemini-2.5-pro", "gemini-2.5-flash"] {
8272 let model = reg.find_by_name(name).expect("model should exist");
8273 assert!(model.has_capability(ModelCapability::Vision));
8274 assert!(model.has_capability(ModelCapability::ToolUse));
8275 assert!(model.has_capability(ModelCapability::MultiToolCall));
8276 }
8277 }
8278
8279 #[test]
8280 fn model_info_publishes_declared_prices_and_keeps_unpriced_distinct_from_free() {
8281 let reg = test_registry();
8282
8283 let opus = reg
8285 .list()
8286 .into_iter()
8287 .find(|m| m.id == "openrouter/anthropic/claude-opus-4.8")
8288 .map(ModelInfo::from)
8289 .expect("curated opus-4.8 row is present on first boot");
8290 assert_eq!(opus.cost.input_per_mtok, Some(5.0));
8291 assert_eq!(opus.cost.output_per_mtok, Some(25.0));
8292 assert_eq!(opus.cost.cache_read_input_per_mtok, Some(0.5));
8293 assert_eq!(opus.cost.cache_write_input_per_mtok, Some(6.25));
8294
8295 let gpt = reg
8297 .list()
8298 .into_iter()
8299 .find(|m| m.id == "openrouter/openai/gpt-5.4")
8300 .map(ModelInfo::from)
8301 .expect("curated gpt-5.4 row");
8302 assert_eq!(gpt.cost.pricing_tiers.len(), 1);
8303 assert_eq!(gpt.cost.prices_for(272_000).input_per_mtok, Some(5.0));
8304
8305 let local = reg
8308 .list()
8309 .into_iter()
8310 .find(|m| m.is_local() && m.cost.input_per_mtok.is_none())
8311 .map(ModelInfo::from)
8312 .expect("the built-in catalog ships unpriced local models");
8313 let json = serde_json::to_value(&local).unwrap();
8314 assert!(json["cost"]["input_per_mtok"].is_null());
8315 assert!(json["cost"]["output_per_mtok"].is_null());
8316 assert_ne!(json["cost"]["input_per_mtok"], serde_json::json!(0.0));
8317 }
8318
8319 #[test]
8320 fn a_hand_registered_copy_of_a_curated_id_does_not_double_the_row() {
8321 let mut reg = test_registry();
8322 let id = "openrouter/anthropic/claude-opus-4.8";
8323 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
8324
8325 let mut copy = reg
8328 .list()
8329 .into_iter()
8330 .find(|m| m.id == id)
8331 .cloned()
8332 .expect("curated row");
8333 copy.name = "hand-registered".into();
8334 reg.register_user_model(copy);
8335 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
8336 }
8337
8338 #[test]
8346 fn managed_alias_publishes_prices_without_disclosing_the_upstream_id_in_the_catalog_view() {
8347 let reg = test_registry();
8348 let alias = reg
8349 .list()
8350 .into_iter()
8351 .find(|m| m.id == "parslee/openrouter/frontier-deep-next")
8352 .map(ModelInfo::from)
8353 .expect("managed alias for the new curated row");
8354
8355 assert_eq!(alias.cost.input_per_mtok, Some(5.0));
8356 assert_eq!(alias.cost.output_per_mtok, Some(25.0));
8357 assert_eq!(alias.cost.cache_read_input_per_mtok, Some(0.5));
8358 assert_eq!(alias.cost.cache_write_input_per_mtok, Some(6.25));
8359
8360 let wire = serde_json::to_string(&alias).unwrap();
8361 assert!(!wire.contains("claude-opus-4.8"));
8362 assert!(!wire.contains("anthropic/"));
8363 }
8364
8365 #[test]
8366 fn model_info_from_an_older_daemon_without_cost_still_parses() {
8367 let legacy = serde_json::json!({
8370 "id": "legacy/model",
8371 "name": "legacy",
8372 "provider": "legacy",
8373 "capabilities": ["generate"],
8374 "param_count": "",
8375 "size_mb": 0,
8376 "context_length": 8192,
8377 "available": true,
8378 "is_local": false
8379 });
8380 let info: ModelInfo = serde_json::from_value(legacy).expect("older catalog row parses");
8381 assert!(info.cost.input_per_mtok.is_none());
8382 assert!(info.cost.output_per_mtok.is_none());
8383 assert!(info.cost.pricing_tiers.is_empty());
8384 assert!(info.max_output_tokens.is_none());
8385 assert!(info.car_enabled, "legacy rows default to enabled");
8386 assert!(!info.can_remove);
8387 assert!(!info.in_use);
8388 assert!(info.management_evidence.is_none());
8389 }
8390
8391 #[test]
8392 fn visual_generation_models_are_curated() {
8393 let reg = test_registry();
8394 assert_eq!(
8395 reg.query_by_capability(ModelCapability::ImageGeneration)
8396 .len(),
8397 1
8398 );
8399 assert_eq!(
8400 reg.query_by_capability(ModelCapability::VideoGeneration)
8401 .len(),
8402 1
8403 );
8404 }
8405}
8406
8407#[cfg(test)]
8416mod builtin_catalog_validation {
8417 use super::*;
8418 use crate::schema::ModelSource;
8419
8420 fn weight_repo(source: &ModelSource) -> Option<&str> {
8422 match source {
8423 ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
8424 ModelSource::Local { hf_repo, .. } => Some(hf_repo),
8425 ModelSource::ManagedVllmMlx { hf_repo, .. } => Some(hf_repo),
8426 _ => None,
8427 }
8428 }
8429
8430 #[test]
8431 fn ids_are_unique() {
8432 let catalog = builtin_catalog();
8433 let mut seen: Vec<&str> = Vec::new();
8434 for model in &catalog {
8435 assert!(
8436 !seen.contains(&model.id.as_str()),
8437 "duplicate catalog id `{}` — the later entry silently shadows the earlier",
8438 model.id
8439 );
8440 seen.push(&model.id);
8441 }
8442 }
8443
8444 #[test]
8445 fn source_kind_is_the_serialized_source_tag() {
8446 for model in builtin_catalog() {
8447 let tagged = serde_json::to_value(&model.source).unwrap();
8448 assert_eq!(
8449 tagged["type"].as_str(),
8450 Some(model.source.kind()),
8451 "{}",
8452 model.id
8453 );
8454 assert_eq!(
8455 crate::ModelInfo::from(&model).source_kind,
8456 model.source.kind()
8457 );
8458 }
8459 let codex = builtin_catalog()
8460 .into_iter()
8461 .find(|model| model.id == "openai/gpt-5.6-sol:high")
8462 .unwrap();
8463 let api = builtin_catalog()
8464 .into_iter()
8465 .find(|model| model.id == "openai/gpt-5.6-sol:latest")
8466 .unwrap();
8467 assert_eq!(crate::ModelInfo::from(&codex).source_kind, "codex_cli");
8468 assert_eq!(crate::ModelInfo::from(&api).source_kind, "remote_api");
8469 }
8470
8471 #[test]
8472 fn exact_frontier_rows_lock_native_selectors_and_digests() {
8473 let catalog = builtin_catalog();
8474 for (id, name, version, expected_digest) in [
8475 (
8476 "openai/gpt-5.5-2026-04-23",
8477 "gpt-5.5-2026-04-23",
8478 "2026-04-23",
8479 "5b2daeae9d9115bab030e4a0630402340198ef56aaf839d6d04f22b9a1bd8eb6",
8480 ),
8481 (
8482 "anthropic/claude-opus-4-8",
8483 "claude-opus-4-8",
8484 "4.8",
8485 "f430356ae863eae3e6509443f94099d6588dba838f0a2214e574d731c8f6ed82",
8486 ),
8487 (
8488 "openai/gpt-5.6-sol:high",
8489 "gpt-5.6-sol:high",
8490 "latest",
8491 "529c449ccc425384a3af7068fca9b9a42d040f0c34cb1275dcd831c2f42e93f7",
8492 ),
8493 ] {
8494 let row = catalog
8495 .iter()
8496 .find(|model| model.id == id)
8497 .unwrap_or_else(|| panic!("missing exact production row {id}"));
8498 assert_eq!(row.name, name);
8499 assert_eq!(row.version, version);
8500 assert_eq!(
8501 crate::catalog_identity::row_digest(row).unwrap(),
8502 expected_digest,
8503 "CAR row digest drifted for {id}"
8504 );
8505 }
8506 }
8507
8508 #[test]
8509 fn weight_repos_are_well_formed_huggingface_ids() {
8510 for model in builtin_catalog() {
8511 let Some(repo) = weight_repo(&model.source) else {
8512 continue;
8513 };
8514 assert_eq!(
8515 repo.split('/').count(),
8516 2,
8517 "{}: `{repo}` is not an `org/name` HuggingFace id",
8518 model.id
8519 );
8520 assert!(
8521 !repo.split('/').any(str::is_empty),
8522 "{}: `{repo}` has an empty path segment",
8523 model.id
8524 );
8525 assert!(
8526 !repo.contains(char::is_whitespace),
8527 "{}: `{repo}` contains whitespace",
8528 model.id
8529 );
8530 }
8531 }
8532
8533 #[test]
8537 fn param_counts_are_parseable_or_deliberately_empty() {
8538 for model in builtin_catalog() {
8539 if weight_repo(&model.source).is_none() || model.param_count.is_empty() {
8540 continue;
8541 }
8542 assert!(
8543 model.param_count.starts_with(|c: char| c.is_ascii_digit()),
8544 "{}: param_count `{}` does not start with a number, so the quality \
8545 prior cannot read it — leave it empty rather than descriptive",
8546 model.id,
8547 model.param_count
8548 );
8549 }
8550 }
8551
8552 #[test]
8555 fn catalog_vllm_mlx_entries_use_explicit_managed_ownership() {
8556 for model in builtin_catalog() {
8557 if !model.is_vllm_mlx() {
8558 continue;
8559 }
8560 assert!(
8561 model.is_car_managed_vllm_mlx(),
8562 "{}: a CAR-supervised catalog row must opt into ManagedVllmMlx; \
8563 loopback alone cannot confer ownership",
8564 model.id
8565 );
8566 }
8567 }
8568
8569 #[test]
8570 fn generate_capable_models_declare_a_context_window() {
8571 for model in builtin_catalog() {
8572 if !model.has_capability(crate::schema::ModelCapability::Generate) {
8573 continue;
8574 }
8575 assert!(
8576 model.context_length > 0,
8577 "{}: a generate-capable model with no context_length breaks budget sizing",
8578 model.id
8579 );
8580 }
8581 }
8582
8583 #[test]
8584 fn every_entry_declares_at_least_one_capability() {
8585 for model in builtin_catalog() {
8586 assert!(
8587 !model.capabilities.is_empty(),
8588 "{}: an entry with no capabilities can never be routed to",
8589 model.id
8590 );
8591 }
8592 }
8593}
8594
8595#[cfg(test)]
8596mod gguf_quantization_tests {
8597 use crate::schema::{QuantScheme, Quantization};
8598
8599 fn quantization_from_gguf_filename(name: &str) -> Option<Quantization> {
8600 Quantization::from_gguf_filename(name)
8601 }
8602
8603 #[test]
8604 fn reads_the_quantization_a_gguf_file_names() {
8605 let cases = [
8606 ("Qwen3-8B-Q4_K_M.gguf", "Q4_K_M", QuantScheme::KQuantMixed),
8607 (
8608 "Qwen3-Embedding-0.6B-Q8_0.gguf",
8609 "Q8_0",
8610 QuantScheme::RtnBlock,
8611 ),
8612 (
8613 "ggml-large-v3-turbo-q5_0.gguf",
8614 "q5_0",
8615 QuantScheme::RtnBlock,
8616 ),
8617 ("model-IQ4_XS.gguf", "IQ4_XS", QuantScheme::KQuantMixed),
8618 ];
8619 for (filename, label, scheme) in cases {
8620 let q = quantization_from_gguf_filename(filename)
8621 .unwrap_or_else(|| panic!("no quantization found in {filename}"));
8622 assert_eq!(q.label, label, "label for {filename}");
8623 assert_eq!(q.scheme, scheme, "scheme for {filename}");
8624 }
8625 }
8626
8627 #[test]
8629 fn returns_none_when_the_name_says_nothing() {
8630 for filename in ["model.gguf", "llama-2-7b-chat.gguf", "ggml-base.gguf"] {
8631 assert!(
8632 quantization_from_gguf_filename(filename).is_none(),
8633 "should not have guessed from {filename}"
8634 );
8635 }
8636 }
8637
8638 #[test]
8641 fn the_rightmost_match_wins() {
8642 let q = quantization_from_gguf_filename("q8-experiment-Q4_K_M.gguf").unwrap();
8643 assert_eq!(q.label, "Q4_K_M");
8644 }
8645}
8646
8647#[cfg(test)]
8648mod mlx_executor_discrimination_tests {
8649 use super::*;
8650
8651 #[test]
8663 fn only_in_process_text_rows_need_the_swift_loader() {
8664 let catalog = builtin_catalog();
8665 let row = |id: &str| {
8666 catalog
8667 .iter()
8668 .find(|m| m.id == id)
8669 .unwrap_or_else(|| panic!("{id} missing from the builtin catalog"))
8670 };
8671
8672 for id in [
8673 "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",
8678 ] {
8679 let m = row(id);
8680 assert!(
8681 matches!(m.source, ModelSource::Mlx { .. }),
8682 "{id}: precondition — this test is only meaningful for Mlx-sourced rows"
8683 );
8684 assert!(
8685 !mlx_row_needs_in_process_loader(m),
8686 "{id} runs in a subprocess; its availability must not depend on the \
8687 in-process Swift loader"
8688 );
8689 }
8690
8691 for id in ["mlx/qwen3-4b:4bit", "mlx/gemma-4-12b-it:4bit"] {
8694 assert!(
8695 mlx_row_needs_in_process_loader(row(id)),
8696 "{id} is served in-process and does need the loader"
8697 );
8698 }
8699 }
8700}
8701
8702#[cfg(test)]
8703mod local_availability_tests {
8704 use super::*;
8705 use crate::schema::ModelSchema;
8706 use tempfile::TempDir;
8707
8708 fn gguf_row(id: &str, hf_repo: &str) -> ModelSchema {
8709 let mut schema: ModelSchema = serde_json::from_value(serde_json::json!({
8710 "id": id,
8711 "name": id.replace('/', "-"),
8712 "provider": "qwen",
8713 "family": "qwen3",
8714 "capabilities": ["generate"],
8715 "context_length": 32768,
8716 "param_count": "8B",
8717 "source": {
8718 "type": "local",
8719 "hf_repo": hf_repo,
8720 "hf_filename": "model.gguf",
8721 "tokenizer_repo": hf_repo,
8722 },
8723 "cost": { "size_mb": 4900 },
8724 }))
8725 .unwrap();
8726 schema.available = false;
8727 schema
8728 }
8729
8730 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
8741 #[test]
8742 fn a_declared_repo_is_available_before_it_is_downloaded() {
8743 let _environment = crate::openrouter::test_environment_scope();
8744 let tmp = TempDir::new().unwrap();
8745 let models = tmp.path().join("models");
8746 std::fs::create_dir_all(&models).unwrap();
8747
8748 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
8749 reg.register(gguf_row("qwen/test-8b:q4_k_m", "Qwen/Qwen3-8B-GGUF"));
8750
8751 let model = reg.get("qwen/test-8b:q4_k_m").expect("registered");
8752 assert!(
8753 model.available,
8754 "a GGUF model with a repo to fetch from must not report unavailable \
8755 just because nothing has downloaded it yet"
8756 );
8757 }
8758
8759 #[test]
8763 fn a_row_with_nowhere_to_fetch_from_stays_unavailable() {
8764 let _environment = crate::openrouter::test_environment_scope();
8765 let tmp = TempDir::new().unwrap();
8766 let models = tmp.path().join("models");
8767 std::fs::create_dir_all(&models).unwrap();
8768
8769 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
8770 reg.register(gguf_row("local/scanned", ""));
8771
8772 let model = reg.get("local/scanned").expect("registered");
8773 assert!(
8774 !model.available,
8775 "an empty hf_repo has no download to promise"
8776 );
8777 }
8778
8779 #[test]
8780 fn builtin_credential_names_cover_remote_and_proprietary_auth() {
8781 let names = builtin_credential_env_names();
8782 for name in [
8783 "OPENAI_API_KEY",
8784 "ANTHROPIC_API_KEY",
8785 crate::openrouter::API_KEY_ENV,
8786 ] {
8787 assert!(names.contains(name), "built-in catalog omitted {name}");
8788 }
8789 }
8790}