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 match car_secrets::status_via_operator_broker_or_keychain(&car_secrets::SecretRef::new(
301 car_secrets::DEFAULT_SERVICE,
302 env_var,
303 )) {
304 Ok(status) if status.exists => CredentialPresence::Present,
305 Ok(_) => CredentialPresence::Absent,
306 Err(_) => CredentialPresence::Unknown,
307 }
308}
309
310static CODEX_SIGNED_IN_HINT: std::sync::atomic::AtomicBool =
315 std::sync::atomic::AtomicBool::new(false);
316
317pub fn remember_codex_sign_in(present: bool) {
322 CODEX_SIGNED_IN_HINT.store(present, std::sync::atomic::Ordering::Relaxed);
323}
324
325pub fn passive_codex_oauth_available() -> bool {
328 CODEX_SIGNED_IN_HINT.load(std::sync::atomic::Ordering::Relaxed)
329}
330
331fn passive_parslee_oauth_available() -> bool {
332 matches!(
333 car_auth::credential_authority_hint().state,
334 car_auth::CredentialAuthorityState::Configured
335 )
336}
337
338fn is_work_context_row(schema: &ModelSchema) -> bool {
341 schema.provider.eq_ignore_ascii_case("parslee")
342 && matches!(
343 &schema.source,
344 ModelSource::Proprietary {
345 provider,
346 auth: ProprietaryAuth::OAuth2Pkce { .. },
347 ..
348 } if provider.eq_ignore_ascii_case("parslee")
349 )
350}
351
352fn proprietary_auth_available(
358 model_id: &str,
359 schema_provider: &str,
360 source_provider: &str,
361 auth: &ProprietaryAuth,
362 parslee_oauth_available: bool,
363 resolved: &std::collections::HashMap<String, bool>,
364) -> bool {
365 if crate::openrouter::is_curated_managed_gateway_alias(model_id)
381 && crate::openrouter::gateway_unconfigured()
382 {
383 return false;
384 }
385 if crate::parslee_credential::credential_rejected()
399 && matches!(auth, ProprietaryAuth::OAuth2Pkce { .. })
400 {
401 return false;
402 }
403 match auth {
404 ProprietaryAuth::ApiKeyEnv { env_var } | ProprietaryAuth::BearerTokenEnv { env_var } => {
405 resolved.get(env_var).copied().unwrap_or(false)
406 }
407 ProprietaryAuth::OAuth2Pkce { .. } => {
408 schema_provider.eq_ignore_ascii_case("parslee")
409 && source_provider.eq_ignore_ascii_case("parslee")
410 && parslee_oauth_available
411 }
412 ProprietaryAuth::ChatGptSubscription {} => {
413 schema_provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
414 && source_provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
415 && passive_codex_oauth_available()
416 }
417 }
418}
419
420fn model_upgrade_rules() -> Vec<ModelUpgradeRule> {
421 serde_json::from_str(include_str!("../assets/model-upgrades.json"))
422 .expect("built-in model-upgrades.json should parse")
423}
424
425pub const USER_MODELS_FILE: &str = "models.json";
427
428pub fn user_config_path() -> Option<PathBuf> {
446 car_home::root().map(|root| root.join(USER_MODELS_FILE))
447}
448
449impl UnifiedRegistry {
450 pub fn new(models_dir: PathBuf) -> Self {
454 Self::new_with_state_root(car_home::root_or_relative(), models_dir)
455 }
456
457 pub fn new_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
461 let catalog_public_key = crate::catalog::catalog_public_key();
462 Self::new_with_catalog_public_key(state_root, models_dir, Some(&catalog_public_key))
463 }
464
465 fn new_with_catalog_public_key(
466 state_root: PathBuf,
467 models_dir: PathBuf,
468 catalog_public_key: Option<&str>,
469 ) -> Self {
470 #[cfg(not(test))]
478 let session = SessionProbe::Live;
479 #[cfg(test)]
480 let session = SessionProbe::Inert;
481 Self::new_with_session(state_root, models_dir, catalog_public_key, session)
482 }
483
484 pub(crate) fn new_with_session(
487 state_root: PathBuf,
488 models_dir: PathBuf,
489 catalog_public_key: Option<&str>,
490 session: SessionProbe,
491 ) -> Self {
492 let user_config_path = state_root.join(USER_MODELS_FILE);
493
494 let mut registry = Self {
495 models_dir,
496 state_root,
497 models: HashMap::new(),
498 credential_state_unknown: HashSet::new(),
499 codex_state_unknown: false,
500 openrouter_state_unknown: false,
501 project_model_ids: HashSet::new(),
502 signed_catalog_ids: HashSet::new(),
503 signed_overlay_ids: HashSet::new(),
504 signed_origin: HashMap::new(),
505 retained_ids: HashSet::new(),
506 revoked_ids: HashSet::new(),
507 revoked_builtin_ids: HashSet::new(),
508 builtin_model_ids: HashSet::new(),
509 user_config_ids: HashSet::new(),
510 on_disk_discovered_ids: HashSet::new(),
511 provider_discovered_ids: HashSet::new(),
512 user_config_path,
513 ambient_progress: ProgressSink::none(),
514 session,
515 };
516 registry.load_builtin_catalog();
517 let cached = crate::catalog::load_verified(
524 &crate::catalog::cache_path(®istry.state_root),
525 catalog_public_key,
526 );
527 if let Some(cached) = cached {
528 let origin = std::sync::Arc::new(cached.envelope());
529 let revoked: HashSet<String> = cached.revoked().iter().cloned().collect();
530 registry.replace_signed_catalog(
531 cached.into_models(),
532 Some(origin),
533 &HashSet::new(),
534 &revoked,
535 );
536 }
537 registry.restore_retained(crate::catalog::load_retained(
538 &crate::catalog::retained_path(®istry.state_root),
539 catalog_public_key,
540 ));
541 for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
549 ®istry.state_models_dir(),
550 )) {
551 if !registry.models.contains_key(&schema.id) && !registry.is_revoked(&schema.id) {
552 registry.provider_discovered_ids.insert(schema.id.clone());
553 registry.register(schema);
554 }
555 }
556 registry.refresh_availability();
557 let _ = registry.load_user_config();
559 registry.discover_on_disk_models();
563 registry
564 }
565
566 fn empty_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
567 let user_config_path = state_root.join(USER_MODELS_FILE);
568 Self {
569 models_dir,
570 state_root,
571 models: HashMap::new(),
572 credential_state_unknown: HashSet::new(),
573 codex_state_unknown: false,
574 openrouter_state_unknown: false,
575 project_model_ids: HashSet::new(),
576 signed_catalog_ids: HashSet::new(),
577 signed_overlay_ids: HashSet::new(),
578 signed_origin: HashMap::new(),
579 retained_ids: HashSet::new(),
580 revoked_ids: HashSet::new(),
581 revoked_builtin_ids: HashSet::new(),
582 builtin_model_ids: HashSet::new(),
583 user_config_ids: HashSet::new(),
584 on_disk_discovered_ids: HashSet::new(),
585 provider_discovered_ids: HashSet::new(),
586 user_config_path,
587 ambient_progress: ProgressSink::none(),
588 session: SessionProbe::Inert,
591 }
592 }
593
594 #[cfg(test)]
609 pub fn new_empty(models_dir: PathBuf) -> Self {
610 let state_root = models_dir.parent().unwrap_or(&models_dir).to_path_buf();
611 Self::empty_with_state_root(state_root, models_dir)
612 }
613
614 pub(crate) fn single(schema: ModelSchema) -> Self {
618 let scratch = std::env::temp_dir();
619 let mut registry = Self::empty_with_state_root(scratch.clone(), scratch);
620 registry.register(schema);
621 registry
622 }
623
624 #[cfg(test)]
629 pub(crate) fn set_available_for_test(&mut self, id: &str, available: bool) {
630 if let Some(row) = self.models.get_mut(id) {
631 row.available = available;
632 }
633 }
634
635 pub(crate) fn new_isolated_for_diagnosis(state_root: PathBuf, models_dir: PathBuf) -> Self {
642 let mut registry = Self::empty_with_state_root(state_root, models_dir);
643 registry.discover_on_disk_models();
644 registry
645 }
646
647 fn state_models_dir(&self) -> PathBuf {
652 self.state_root.join("models")
653 }
654
655 fn discover_on_disk_models(&mut self) {
669 let entries = match std::fs::read_dir(&self.models_dir) {
670 Ok(e) => e,
671 Err(_) => return,
672 };
673 let known: std::collections::HashSet<String> = self
678 .models
679 .values()
680 .map(|m| m.name.to_ascii_lowercase())
681 .chain(
682 crate::catalog::load_revoked_names(&crate::catalog::revoked_names_path(
683 &self.state_root,
684 ))
685 .into_keys()
686 .map(|name| name.to_ascii_lowercase()),
687 )
688 .collect();
689
690 for entry in entries.flatten() {
691 let path = entry.path();
692 if !path.is_dir() {
693 continue;
694 }
695 let Some(name) = path
696 .file_name()
697 .and_then(|n| n.to_str())
698 .map(str::to_string)
699 else {
700 continue;
701 };
702 if known.contains(&name.to_ascii_lowercase()) {
703 continue;
704 }
705
706 let Some(schema) = synthesize_local_schema(&name, &path) else {
707 continue;
708 };
709 if self.models.contains_key(&schema.id) {
715 tracing::debug!(
716 id = %schema.id,
717 name = %name,
718 "skipping local model directory whose id is already registered (car#1650)"
719 );
720 continue;
721 }
722 tracing::info!(
723 id = %schema.id,
724 name = %name,
725 "auto-discovered uncatalogued local model under models_dir (car-releases#62)"
726 );
727 let id = schema.id.clone();
728 self.register(schema);
729 if self.models.contains_key(&id) {
730 self.on_disk_discovered_ids.insert(id);
731 }
732 }
733 }
734
735 pub(crate) fn prune_missing_on_disk_models(&mut self) {
742 let missing = self
743 .on_disk_discovered_ids
744 .iter()
745 .filter(|id| {
746 self.models
747 .get(id.as_str())
748 .is_none_or(|schema| !self.models_dir.join(&schema.name).is_dir())
749 })
750 .cloned()
751 .collect::<Vec<_>>();
752 for id in missing {
753 tracing::debug!(
754 model_id = %id,
755 "dropping vanished auto-discovered model; coordination records do not register models"
756 );
757 self.on_disk_discovered_ids.remove(&id);
758 self.models.remove(&id);
759 }
760 }
761
762 pub fn register(&mut self, mut schema: ModelSchema) {
770 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
771 warn!(id = %schema.id, "ignoring user registration for reserved Parslee-managed alias");
772 return;
773 }
774 if self.project_model_ids.contains(&schema.id) {
775 warn!(id = %schema.id, "ignoring public registration for project-owned exact id");
776 return;
777 }
778 schema.mark_user_registered();
779 let id = schema.id.clone();
780 if self.register_preserving_trust(schema) {
781 self.on_disk_discovered_ids.remove(&id);
782 }
783 }
784
785 pub(crate) fn register_project_model(&mut self, schema: ModelSchema) -> bool {
791 let id = schema.id.clone();
792 if !self.register_preserving_trust(schema) {
793 return false;
794 }
795 self.on_disk_discovered_ids.remove(&id);
796 self.project_model_ids.insert(id);
797 true
798 }
799
800 pub(crate) fn replace_signed_catalog(
816 &mut self,
817 rows: Vec<ModelSchema>,
818 origin: Option<std::sync::Arc<crate::catalog::CatalogCacheEnvelope>>,
819 keep: &HashSet<String>,
820 revoked: &HashSet<String>,
821 ) -> usize {
822 let incoming: HashSet<&str> = rows.iter().map(|m| m.id.as_str()).collect();
823 for id in std::mem::take(&mut self.signed_catalog_ids) {
824 if keep.contains(&id) && !incoming.contains(id.as_str()) && !revoked.contains(&id) {
827 if let Some(row) = self.models.get_mut(&id) {
828 row.deprecated = true;
829 }
830 self.retained_ids.insert(id.clone());
831 self.signed_catalog_ids.insert(id);
832 continue;
833 }
834 self.models.remove(&id);
835 self.project_model_ids.remove(&id);
836 self.credential_state_unknown.remove(&id);
837 self.signed_origin.remove(&id);
838 self.retained_ids.remove(&id);
839 }
840 let overlaid = std::mem::take(&mut self.signed_overlay_ids);
841 let unrevoked = std::mem::take(&mut self.revoked_builtin_ids);
842 let restore: HashSet<String> = overlaid.union(&unrevoked).cloned().collect();
843 let compiled: HashMap<String, ModelSchema> = if restore.is_empty() {
844 HashMap::new()
845 } else {
846 builtin_catalog()
847 .into_iter()
848 .filter(|m| restore.contains(&m.id))
849 .map(|m| (m.id.clone(), m))
850 .collect()
851 };
852 for id in &restore {
853 if let (Some(row), Some(original)) = (self.models.get_mut(id), compiled.get(id)) {
854 Self::copy_overlay_fields(original, row);
855 }
856 }
857 let lifted: HashSet<String> = std::mem::replace(&mut self.revoked_ids, revoked.clone())
858 .difference(revoked)
859 .cloned()
860 .collect();
861 for id in revoked {
864 if self.provider_discovered_ids.remove(id) && !self.user_config_ids.contains(id) {
865 self.models.remove(id);
866 }
867 }
868 let mut loaded = 0;
869 for schema in rows {
870 let id = schema.id.clone();
871 if revoked.contains(&id) {
872 warn!(%id, "ignoring a signed catalog row its own catalog revokes");
873 continue;
874 }
875 if self.register_signed_catalog_model(schema) {
876 loaded += 1;
877 if let Some(origin) = &origin {
878 if self.signed_catalog_ids.contains(&id) {
879 self.signed_origin.insert(id, origin.clone());
880 }
881 }
882 }
883 }
884 if !lifted.is_empty() {
887 for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
888 &self.state_models_dir(),
889 )) {
890 if lifted.contains(&schema.id) && !self.models.contains_key(&schema.id) {
891 self.provider_discovered_ids.insert(schema.id.clone());
892 self.register(schema);
893 }
894 }
895 }
896 for id in revoked {
899 if self.builtin_model_ids.contains(id) {
900 if let Some(row) = self.models.get_mut(id) {
901 row.deprecated = true;
902 self.revoked_builtin_ids.insert(id.clone());
903 }
904 }
905 }
906 loaded
907 }
908
909 pub fn is_revoked(&self, id: &str) -> bool {
911 self.revoked_ids.contains(id)
912 }
913
914 pub fn revoked_ids(&self) -> Vec<String> {
916 self.revoked_ids.iter().cloned().collect()
917 }
918
919 pub fn is_builtin(&self, id: &str) -> bool {
922 self.builtin_model_ids.contains(id)
923 }
924
925 pub(crate) fn retained_rows(
928 &self,
929 ) -> Vec<(String, std::sync::Arc<crate::catalog::CatalogCacheEnvelope>)> {
930 let mut rows: Vec<_> = self
931 .retained_ids
932 .iter()
933 .filter_map(|id| Some((id.clone(), self.signed_origin.get(id)?.clone())))
934 .collect();
935 rows.sort_by(|a, b| a.0.cmp(&b.0));
936 rows
937 }
938
939 pub(crate) fn signed_origin_of(
941 &self,
942 id: &str,
943 ) -> Option<std::sync::Arc<crate::catalog::CatalogCacheEnvelope>> {
944 self.signed_origin.get(id).cloned()
945 }
946
947 pub(crate) fn restore_retained(
951 &mut self,
952 rows: Vec<(
953 ModelSchema,
954 std::sync::Arc<crate::catalog::CatalogCacheEnvelope>,
955 )>,
956 ) {
957 for (mut schema, origin) in rows {
958 if self.models.contains_key(&schema.id) || self.revoked_ids.contains(&schema.id) {
959 continue;
960 }
961 schema.deprecated = true;
962 let id = schema.id.clone();
963 if self.register_signed_catalog_model(schema) && self.signed_catalog_ids.contains(&id) {
964 self.signed_origin.insert(id.clone(), origin);
965 self.retained_ids.insert(id);
966 }
967 }
968 }
969
970 fn copy_overlay_fields(from: &ModelSchema, to: &mut ModelSchema) {
973 to.public_benchmarks = from.public_benchmarks.clone();
974 to.cost = from.cost.clone();
975 to.performance = from.performance.clone();
976 to.deprecated = from.deprecated;
977 }
978
979 fn overlay_builtin(&mut self, schema: &ModelSchema) -> bool {
983 let Some(row) = self.models.get_mut(&schema.id) else {
984 return false;
985 };
986 let same_weights =
987 serde_json::to_value(&row.source).ok() == serde_json::to_value(&schema.source).ok();
988 if !same_weights {
989 warn!(id = %schema.id, "ignoring signed overlay that names different weights for a compiled builtin");
990 return false;
991 }
992 Self::copy_overlay_fields(schema, row);
993 self.signed_overlay_ids.insert(schema.id.clone());
994 true
995 }
996
997 fn register_signed_catalog_model(&mut self, schema: ModelSchema) -> bool {
1001 if self.builtin_model_ids.contains(&schema.id) {
1002 if self.signed_overlay_ids.contains(&schema.id) {
1003 warn!(id = %schema.id, "ignoring duplicate signed overlay for compiled builtin");
1004 return false;
1005 }
1006 return self.overlay_builtin(&schema);
1007 }
1008 if self.user_config_ids.contains(&schema.id) {
1009 warn!(id = %schema.id, "ignoring signed catalog row for a user-registered id");
1010 return false;
1011 }
1012 if self.project_model_ids.contains(&schema.id) {
1013 warn!(id = %schema.id, "ignoring duplicate signed catalog row for project-owned exact id");
1014 return false;
1015 }
1016 let id = schema.id.clone();
1017 let registered = self.register_project_model(schema);
1018 if registered {
1019 self.signed_catalog_ids.insert(id);
1020 }
1021 registered
1022 }
1023
1024 fn register_preserving_trust(&mut self, mut schema: ModelSchema) -> bool {
1025 if let Err(error) = crate::catalog_identity::row_digest(&schema) {
1026 warn!(id = %schema.id, %error, "rejecting model without canonical catalog identity");
1027 return false;
1028 }
1029 if schema.is_mlx() || schema.is_car_managed_vllm_mlx() {
1031 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1042 {
1043 schema.available = if schema.tags.contains(&"speech".to_string()) {
1044 speech_mlx_available()
1045 } else if let ModelSource::Mlx { ref hf_repo, .. }
1046 | ModelSource::ManagedVllmMlx { ref hf_repo, .. } = schema.source
1047 {
1048 let loader_ok = !mlx_row_needs_in_process_loader(&schema)
1062 || crate::backend::local::in_process_loader_available();
1063 let mlx_dir = self.models_dir.join(&schema.name);
1064 loader_ok && (mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty())
1065 } else {
1066 let mlx_dir = self.models_dir.join(&schema.name);
1067 mlx_dir_has_weights(&mlx_dir)
1068 };
1069 }
1070 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1071 {
1072 schema.available = false;
1073 }
1074 } else if schema.is_vllm_mlx() {
1075 schema.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available;
1077 } else if matches!(schema.source, ModelSource::WhisperCpp { .. }) {
1078 schema.available = true;
1083 } else if matches!(schema.source, ModelSource::WindowsSpeech {}) {
1084 schema.available = cfg!(target_os = "windows");
1086 } else if schema.is_codex_cli() {
1087 schema.available = crate::backend::codex_cli::is_available();
1091 } else if schema.is_local() {
1092 let local_path = self.models_dir.join(&schema.name).join("model.gguf");
1093 let lazily_fetchable = matches!(
1099 schema.source,
1100 ModelSource::Local { ref hf_repo, .. } if !hf_repo.is_empty()
1101 ) && !cfg!(all(
1102 target_os = "macos",
1103 target_arch = "aarch64",
1104 not(car_skip_mlx)
1105 ));
1106 schema.available = local_path.exists() || lazily_fetchable;
1107 } else if schema.is_remote() {
1108 schema.available = match schema.source {
1112 ModelSource::RemoteApi {
1113 protocol: crate::schema::ApiProtocol::OpenRouter,
1114 ..
1115 } => crate::openrouter::credential_source().is_some(),
1116 ModelSource::RemoteApi {
1117 ref api_key_env, ..
1118 } => environment_credential_available(api_key_env),
1119 ModelSource::Proprietary {
1120 ref provider,
1121 ref auth,
1122 ..
1123 } => {
1124 let resolved = match auth {
1125 ProprietaryAuth::ApiKeyEnv { env_var }
1126 | ProprietaryAuth::BearerTokenEnv { env_var } => {
1127 std::collections::HashMap::from([(
1128 env_var.clone(),
1129 environment_credential_available(env_var),
1130 )])
1131 }
1132 ProprietaryAuth::OAuth2Pkce { .. }
1133 | ProprietaryAuth::ChatGptSubscription {} => Default::default(),
1134 };
1135 proprietary_auth_available(
1136 &schema.id,
1137 &schema.provider,
1138 provider,
1139 auth,
1140 self.session.available(),
1141 &resolved,
1142 )
1143 }
1144 _ => schema.available,
1145 };
1146 }
1147 schema.weights_ready = physical_weights_ready(&schema, &self.models_dir);
1160 debug!(
1172 id = %schema.id,
1173 name = %schema.name,
1174 available = schema.available,
1175 weights_ready = schema.weights_ready,
1176 "registered model"
1177 );
1178 self.models.insert(schema.id.clone(), schema);
1179 true
1180 }
1181
1182 pub fn register_user_model(&mut self, mut schema: ModelSchema) {
1189 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
1190 warn!(id = %schema.id, "ignoring persisted user model for reserved Parslee-managed alias");
1191 return;
1192 }
1193 if self.project_model_ids.contains(&schema.id) {
1194 warn!(id = %schema.id, "ignoring persisted user model for project-owned exact id");
1195 return;
1196 }
1197 schema.mark_user_registered();
1198 let id = schema.id.clone();
1199 if self.register_preserving_trust(schema) {
1200 self.on_disk_discovered_ids.remove(&id);
1201 self.provider_discovered_ids.remove(&id);
1202 self.user_config_ids.insert(id);
1203 }
1204 }
1205
1206 pub fn unregister(&mut self, id: &str) -> Option<ModelSchema> {
1208 self.on_disk_discovered_ids.remove(id);
1209 self.provider_discovered_ids.remove(id);
1210 let removed = self.models.remove(id);
1211 if let Some(ref m) = removed {
1212 info!(id = %m.id, "unregistered model");
1213 }
1214 removed
1215 }
1216
1217 pub fn unregister_user_model(&mut self, id: &str) -> Option<ModelSchema> {
1222 if !self.user_config_ids.remove(id) {
1223 return None;
1224 }
1225 self.unregister(id)
1226 }
1227
1228 pub fn list(&self) -> Vec<&ModelSchema> {
1230 let mut models: Vec<&ModelSchema> = self.models.values().collect();
1231 models.sort_by(|a, b| a.id.cmp(&b.id));
1232 models
1233 }
1234
1235 pub fn query(&self, filter: &ModelFilter) -> Vec<&ModelSchema> {
1237 self.models
1238 .values()
1239 .filter(|m| {
1240 if !filter.capabilities.iter().all(|c| m.has_capability(*c)) {
1242 return false;
1243 }
1244 if let Some(max) = filter.max_size_mb {
1246 if m.size_mb() > max && m.is_local() {
1247 return false;
1248 }
1249 }
1250 if let Some(max) = filter.max_latency_ms {
1252 if let Some(p50) = m.performance.latency_p50_ms {
1253 if p50 > max {
1254 return false;
1255 }
1256 }
1257 }
1258 if let Some(max) = filter.max_cost_per_mtok {
1260 if let Some(cost) = m.cost.output_per_mtok {
1261 if cost > max {
1262 return false;
1263 }
1264 }
1265 }
1266 if !filter.tags.iter().all(|t| m.tags.contains(t)) {
1268 return false;
1269 }
1270 if let Some(ref p) = filter.provider {
1272 if &m.provider != p {
1273 return false;
1274 }
1275 }
1276 if filter.local_only && !m.is_local() {
1278 return false;
1279 }
1280 if filter.available_only && !m.available_now() {
1282 return false;
1283 }
1284 true
1285 })
1286 .collect()
1287 }
1288
1289 pub fn query_by_capability(&self, cap: ModelCapability) -> Vec<&ModelSchema> {
1291 self.query(&ModelFilter {
1292 capabilities: vec![cap],
1293 ..Default::default()
1294 })
1295 }
1296
1297 pub fn available_upgrades(&self) -> Vec<ModelUpgrade> {
1299 let mut upgrades = Vec::new();
1300 for rule in model_upgrade_rules() {
1301 let Some(from) = rule
1302 .from_ids
1303 .iter()
1304 .find_map(|id| self.models.get(id.as_str()))
1305 .filter(|schema| schema.available)
1306 else {
1307 continue;
1308 };
1309 let Some(to) = self.models.get(rule.to_id.as_str()) else {
1310 continue;
1311 };
1312 upgrades.push(ModelUpgrade {
1313 from_id: from.id.clone(),
1314 from_name: from.name.clone(),
1315 to_id: to.id.clone(),
1316 to_name: to.name.clone(),
1317 reason: rule.reason.clone(),
1318 target_runtime: rule.target_runtime.clone(),
1319 target_runtime_requirement: rule.target_runtime_requirement.clone(),
1320 minimum_runtimes: rule.minimum_runtimes.clone(),
1321 target_available: to.available,
1322 target_pullable: matches!(
1323 to.source,
1324 ModelSource::Local { .. } | ModelSource::Mlx { .. }
1325 ),
1326 remove_old_supported: matches!(
1327 from.source,
1328 ModelSource::Local { .. } | ModelSource::Mlx { .. }
1329 ) && rule.remove_old_after_available,
1330 });
1331 }
1332 upgrades.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
1333 upgrades.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
1334 upgrades
1335 }
1336
1337 pub(crate) fn curated_replacement(&self, id: &str) -> Option<&ModelSchema> {
1344 model_upgrade_rules()
1345 .into_iter()
1346 .find(|rule| rule.from_ids.iter().any(|from| from == id))
1347 .and_then(|rule| self.models.get(rule.to_id.as_str()))
1348 .filter(|to| to.id != id)
1349 }
1350
1351 pub fn get(&self, id: &str) -> Option<&ModelSchema> {
1353 self.models.get(id)
1354 }
1355
1356 pub(crate) fn get_by_id_or_name(&self, model: &str) -> Option<&ModelSchema> {
1359 self.get(model).or_else(|| self.find_by_name(model))
1360 }
1361
1362 pub fn registered_schema(&self, id: &str) -> Option<&ModelSchema> {
1368 self.get(id)
1369 }
1370
1371 pub fn all(&self) -> impl Iterator<Item = &ModelSchema> {
1373 self.models.values()
1374 }
1375
1376 pub fn find_by_name(&self, name: &str) -> Option<&ModelSchema> {
1387 find_named(name, self.models.values(), |m| &m.name).or_else(|| {
1388 if self.models.contains_key(name) {
1389 return None;
1390 }
1391 self.models.get(&implicit_latest_id(name)?)
1392 })
1393 }
1394
1395 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1399 pub fn resolve_mlx_equivalent(&self, schema: &ModelSchema) -> Option<&ModelSchema> {
1400 if schema.is_mlx() || schema.is_vllm_mlx() {
1402 return None;
1403 }
1404 if !matches!(schema.source, ModelSource::Local { .. }) {
1406 return None;
1407 }
1408 let primary_cap = schema.capabilities.first()?;
1415 self.models.values().find(|m| {
1416 m.is_mlx()
1417 && m.family == schema.family
1418 && m.param_count == schema.param_count
1419 && m.capabilities.contains(primary_cap)
1420 })
1421 }
1422
1423 pub async fn ensure_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1425 let sink = self.ambient_progress.clone();
1430 self.ensure_local_with_progress(id, &sink).await
1431 }
1432
1433 pub fn set_ambient_progress(&mut self, sink: ProgressSink) {
1440 self.ambient_progress = sink;
1441 }
1442
1443 pub async fn ensure_local_with_progress(
1450 &self,
1451 id: &str,
1452 sink: &ProgressSink,
1453 ) -> Result<PathBuf, InferenceError> {
1454 self.acquire_and_ensure(id, sink, false, None).await
1455 }
1456
1457 pub(crate) async fn ensure_local_with_progress_staged(
1462 &self,
1463 id: &str,
1464 sink: &ProgressSink,
1465 staging_dir: &Path,
1466 ) -> Result<PathBuf, InferenceError> {
1467 self.acquire_and_ensure(id, sink, false, Some(staging_dir))
1468 .await
1469 }
1470
1471 pub async fn redownload_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1477 self.acquire_and_ensure(id, &ProgressSink::none(), true, None)
1478 .await
1479 }
1480
1481 async fn acquire_and_ensure(
1482 &self,
1483 id: &str,
1484 sink: &ProgressSink,
1485 force: bool,
1486 managed_dir_override: Option<&Path>,
1487 ) -> Result<PathBuf, InferenceError> {
1488 let schema = self
1489 .get(id)
1490 .or_else(|| self.find_by_name(id))
1491 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1492 if never_download_in_scope() {
1497 if !force && managed_dir_override.is_none() {
1498 if let Some(path) = self.try_reuse_local(schema).await? {
1499 return Ok(path);
1500 }
1501 }
1502 return Err(refuse_download(schema));
1503 }
1504 let model_name = schema.name.clone();
1505 let model_id = schema.id.clone();
1506 let needed_mb = schema.size_mb();
1507 let model_dir = self.models_dir.join(&schema.name);
1508
1509 let _guard = crate::download::acquire_model_lock(&model_id).await;
1511
1512 if !force && managed_dir_override.is_none() {
1516 if let Some(path) = self.try_reuse_local(schema).await? {
1517 return Ok(path);
1518 }
1519 }
1520
1521 if let Err(e) = crate::download::check_disk_space(&model_dir, needed_mb) {
1523 sink.emit(DownloadEvent::Failed { error: e.clone() });
1524 return Err(InferenceError::DownloadFailed(e));
1525 }
1526
1527 sink.emit(DownloadEvent::Started {
1528 model: model_name.clone(),
1529 total_files: 0,
1530 total_mb: needed_mb,
1531 });
1532 let (result, fetched) = crate::retire::collecting_fetches(self.ensure_local_inner(
1535 id,
1536 sink,
1537 force,
1538 managed_dir_override,
1539 ))
1540 .await;
1541 crate::retire::record_download(
1542 &self.state_root,
1543 &crate::hf_cache::hub_dir(),
1544 &model_id,
1545 &fetched,
1546 );
1547 match &result {
1548 Ok(_) => sink.emit(DownloadEvent::Completed { model: model_name }),
1549 Err(e) => sink.emit(DownloadEvent::Failed {
1550 error: e.to_string(),
1551 }),
1552 }
1553 result
1554 }
1555
1556 async fn try_reuse_local(
1557 &self,
1558 schema: &ModelSchema,
1559 ) -> Result<Option<PathBuf>, InferenceError> {
1560 let reused = self.reusable_local_dir(schema);
1561 if let Some(path) = &reused {
1562 info!(model = %schema.name, path = %path.display(), "using local model files already on disk");
1563 }
1564 Ok(reused)
1565 }
1566
1567 fn reusable_local_dir(&self, schema: &ModelSchema) -> Option<PathBuf> {
1570 match &schema.source {
1571 ModelSource::Local { .. } => {
1572 let model_dir = self.models_dir.join(&schema.name);
1573 let model_path = model_dir.join("model.gguf");
1574 let tokenizer_path = model_dir.join("tokenizer.json");
1575
1576 if crate::download::cache_file_usable(&model_path)
1577 && crate::download::cache_file_usable(&tokenizer_path)
1578 {
1579 return Some(model_dir);
1580 }
1581 }
1582 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1583 let model_dir = self.models_dir.join(&schema.name);
1584 let is_diffusers = mlx_schema_is_diffusers(schema);
1585
1586 if mlx_layout_complete(is_diffusers, &model_dir) {
1587 if auxiliary_mlx_files_missing(&schema.name, hf_repo, &model_dir) {
1588 return None;
1592 }
1593 return Some(model_dir);
1594 }
1595
1596 if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1597 .filter(|d| mlx_layout_complete(is_diffusers, d))
1598 {
1599 if !auxiliary_mlx_files_missing(&schema.name, hf_repo, &snapshot_dir) {
1600 return Some(snapshot_dir);
1601 }
1602 }
1603 }
1604 _ => {}
1605 }
1606
1607 None
1608 }
1609
1610 async fn ensure_local_inner(
1611 &self,
1612 id: &str,
1613 sink: &ProgressSink,
1614 force: bool,
1615 managed_dir_override: Option<&Path>,
1616 ) -> Result<PathBuf, InferenceError> {
1617 let schema = self
1618 .get(id)
1619 .or_else(|| self.find_by_name(id))
1620 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1621
1622 match &schema.source {
1623 ModelSource::Local {
1624 hf_repo,
1625 hf_filename,
1626 tokenizer_repo,
1627 } => {
1628 let model_dir = managed_dir_override
1629 .map(Path::to_path_buf)
1630 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1631 let model_path = model_dir.join("model.gguf");
1632 let tokenizer_path = model_dir.join("tokenizer.json");
1633
1634 if !force
1635 && crate::download::cache_file_usable(&model_path)
1636 && crate::download::cache_file_usable(&tokenizer_path)
1637 {
1638 return Ok(model_dir);
1639 }
1640
1641 if hf_repo.is_empty() {
1648 let missing = [
1649 ("model.gguf", &model_path),
1650 ("tokenizer.json", &tokenizer_path),
1651 ]
1652 .into_iter()
1653 .filter(|(_, path)| !crate::download::cache_file_usable(path))
1654 .map(|(name, _)| name)
1655 .collect::<Vec<_>>()
1656 .join(" and ");
1657 return Err(InferenceError::InferenceFailed(format!(
1658 "{}: discovered on disk at {} but not loadable — the GGUF \
1659 backend reads `model.gguf` and `tokenizer.json` from the \
1660 model directory, and this one is missing {missing}. Rename \
1661 the weight file to `model.gguf` and add the tokenizer, or \
1662 register the model against its HuggingFace repo so CAR can \
1663 fetch both.",
1664 schema.name,
1665 model_dir.display()
1666 )));
1667 }
1668
1669 std::fs::create_dir_all(&model_dir)?;
1670
1671 if !crate::download::cache_file_usable(&model_path) {
1672 info!(model = %schema.name, repo = %hf_repo, "downloading model weights");
1673 sink.emit(DownloadEvent::FileStarted {
1674 filename: "model weights".into(),
1675 index: 1,
1676 total_files: 2,
1677 size_mb: schema.size_mb(),
1678 });
1679 download_file(hf_repo, hf_filename, &model_path).await?;
1680 sink.emit(DownloadEvent::FileCompleted {
1681 filename: "model weights".into(),
1682 });
1683 }
1684 if !crate::download::cache_file_usable(&tokenizer_path) {
1685 info!(model = %schema.name, repo = %tokenizer_repo, "downloading tokenizer");
1686 sink.emit(DownloadEvent::FileStarted {
1687 filename: "tokenizer".into(),
1688 index: 2,
1689 total_files: 2,
1690 size_mb: 0,
1691 });
1692 download_file(tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
1693 sink.emit(DownloadEvent::FileCompleted {
1694 filename: "tokenizer".into(),
1695 });
1696 }
1697
1698 Ok(model_dir)
1699 }
1700 ModelSource::Mlx {
1701 hf_repo,
1702 hf_weight_file,
1703 }
1704 | ModelSource::ManagedVllmMlx {
1705 hf_repo,
1706 hf_weight_file,
1707 ..
1708 } => {
1709 let model_dir = managed_dir_override
1710 .map(Path::to_path_buf)
1711 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1712 let config_path = model_dir.join("config.json");
1713
1714 let is_diffusers = mlx_schema_is_diffusers(schema);
1725
1726 if !force && mlx_layout_complete(is_diffusers, &model_dir) {
1737 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1738 info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1739 return Ok(model_dir);
1740 }
1741
1742 if !force {
1746 if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1747 .filter(|d| mlx_layout_complete(is_diffusers, d))
1748 {
1749 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &snapshot_dir).await?;
1750 info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1751 return Ok(snapshot_dir);
1752 }
1753 }
1754
1755 std::fs::create_dir_all(&model_dir)?;
1756
1757 info!(model = %schema.name, repo = %hf_repo, "downloading MLX model");
1758
1759 if is_diffusers {
1766 download_repo_snapshot(hf_repo, &model_dir, sink).await?;
1767 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1768 if !mlx_dir_has_weights(&model_dir) {
1769 return Err(InferenceError::DownloadFailed(format!(
1770 "{hf_repo}: snapshot fetched but no component weights found"
1771 )));
1772 }
1773 info!(model = %schema.name, path = %model_dir.display(), "downloaded diffusers model");
1774 return Ok(model_dir);
1775 }
1776
1777 emit_file(sink, "config", 0, schema.size_mb());
1781 download_file(hf_repo, "config.json", &config_path).await?;
1782 download_tokenizer_assets(hf_repo, &model_dir, sink).await;
1783 let tok_config_path = model_dir.join("tokenizer_config.json");
1784 if !crate::download::cache_file_usable(&tok_config_path) {
1785 let _ = download_file(hf_repo, "tokenizer_config.json", &tok_config_path).await;
1786 }
1787
1788 if let Some(ref wf) = hf_weight_file {
1790 let wf_path = model_dir.join(wf);
1791 if !crate::download::cache_file_usable(&wf_path) {
1792 emit_file(sink, "model weights", 0, schema.size_mb());
1793 download_file(hf_repo, wf, &wf_path).await?;
1794 }
1795 } else {
1796 let single = model_dir.join("model.safetensors");
1798 if !crate::download::cache_file_usable(&single) {
1799 emit_file(sink, "model weights", 0, schema.size_mb());
1800 match download_file(hf_repo, "model.safetensors", &single).await {
1801 Ok(()) => {}
1802 Err(_) => {
1803 let index_path = model_dir.join("model.safetensors.index.json");
1805 download_file(hf_repo, "model.safetensors.index.json", &index_path)
1806 .await?;
1807
1808 let index_json: serde_json::Value =
1809 serde_json::from_str(&std::fs::read_to_string(&index_path)?)
1810 .map_err(|e| {
1811 InferenceError::InferenceFailed(format!(
1812 "parse index: {e}"
1813 ))
1814 })?;
1815
1816 if let Some(weight_map) =
1817 index_json.get("weight_map").and_then(|m| m.as_object())
1818 {
1819 let mut files: std::collections::HashSet<String> =
1820 std::collections::HashSet::new();
1821 for filename in weight_map.values() {
1822 if let Some(f) = filename.as_str() {
1823 files.insert(f.to_string());
1824 }
1825 }
1826 let shard_total = files.len() as u32;
1827 for (i, file) in files.iter().enumerate() {
1828 let dest = model_dir.join(file);
1829 if !crate::download::cache_file_usable(&dest) {
1830 info!(file = %file, "downloading weight shard");
1831 sink.emit(DownloadEvent::FileStarted {
1832 filename: format!("weights part {}", i + 1),
1833 index: (i + 1) as u32,
1834 total_files: shard_total,
1835 size_mb: 0,
1836 });
1837 download_file(hf_repo, file, &dest).await?;
1838 sink.emit(DownloadEvent::FileCompleted {
1839 filename: format!("weights part {}", i + 1),
1840 });
1841 }
1842 }
1843 }
1844 }
1845 }
1846 }
1847 }
1848
1849 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1850
1851 let missing = missing_weight_shards(&model_dir);
1863 if !missing.is_empty() {
1864 return Err(InferenceError::DownloadFailed(format!(
1865 "{}: pull finished but {} weight shard(s) are still missing: {}. \
1866 The download was interrupted; re-run the pull to resume it.",
1867 schema.name,
1868 missing.len(),
1869 missing.join(", ")
1870 )));
1871 }
1872 if !mlx_dir_has_weights(&model_dir) {
1873 return Err(InferenceError::DownloadFailed(format!(
1874 "{}: pull finished but no usable weights are present under {}",
1875 schema.name,
1876 model_dir.display()
1877 )));
1878 }
1879 Ok(model_dir)
1880 }
1881 _ => Err(InferenceError::InferenceFailed(format!(
1882 "model {} is not local",
1883 id
1884 ))),
1885 }
1886 }
1887
1888 #[deprecated(note = "use InferenceEngine::remove_model_from_car")]
1892 pub fn remove_local(&mut self, id: &str) -> Result<(), InferenceError> {
1893 Err(InferenceError::InferenceFailed(format!(
1894 "legacy registry removal for {id} is disabled; use receipt-backed model management"
1895 )))
1896 }
1897
1898 pub fn refresh_availability(&mut self) {
1905 let parslee_oauth_available = self.session.available();
1906 self.refresh_availability_with(
1907 parslee_oauth_available,
1908 self.session.signed_out() && self.session.may_forget_session_evidence(),
1909 false,
1910 );
1911 }
1912
1913 pub fn refresh_availability_for_explicit_status(&mut self) {
1931 let parslee_oauth_available = self.session.available();
1932 self.refresh_availability_with(
1933 parslee_oauth_available,
1934 self.session.signed_out() && self.session.may_forget_session_evidence(),
1935 true,
1936 );
1937 }
1938
1939 pub(crate) fn refresh_routing_availability(
1944 &mut self,
1945 parslee_api_base: Option<&str>,
1946 parslee_signed_out: bool,
1947 ) {
1948 if let Some(api_base) = parslee_api_base {
1949 let api_base = api_base.trim_end_matches('/');
1950 for schema in self.models.values_mut() {
1951 if schema.provider.eq_ignore_ascii_case("parslee") {
1952 if let ModelSource::Proprietary {
1953 provider, endpoint, ..
1954 } = &mut schema.source
1955 {
1956 if provider.eq_ignore_ascii_case("parslee") {
1957 *endpoint = api_base.to_string();
1958 }
1959 }
1960 }
1961 }
1962 }
1963 self.refresh_availability_with(parslee_api_base.is_some(), parslee_signed_out, true);
1964 }
1965
1966 pub(crate) fn refresh_work_context_availability(&mut self, api_base: &str, available: bool) {
1969 self.refresh_availability();
1970 for schema in self.models.values_mut() {
1971 if !is_work_context_row(schema) {
1972 continue;
1973 }
1974 if let ModelSource::Proprietary { endpoint, .. } = &mut schema.source {
1975 *endpoint = api_base.trim_end_matches('/').to_string();
1976 schema.available = available;
1977 }
1978 }
1979 }
1980
1981 pub(crate) fn work_context_serves(&self, model: &str) -> bool {
1987 self.get_by_id_or_name(model)
1988 .is_some_and(|schema| !is_work_context_row(schema) || schema.available)
1989 }
1990
1991 pub fn credential_state_is_unknown(&self, env_var: &str) -> bool {
1994 self.credential_state_unknown.contains(env_var)
1995 }
1996
1997 fn record_codex_lookup(&mut self, lookup: Result<bool, String>) {
2002 match lookup {
2003 Ok(present) => remember_codex_sign_in(present),
2004 Err(_) => {
2005 remember_codex_sign_in(false);
2006 self.codex_state_unknown = true;
2007 }
2008 }
2009 }
2010
2011 pub fn row_credential_state_unknown(&self, schema: &ModelSchema) -> bool {
2016 match &schema.source {
2017 ModelSource::RemoteApi {
2018 protocol: crate::schema::ApiProtocol::OpenRouter,
2019 ..
2020 } => self.openrouter_state_unknown,
2021 ModelSource::RemoteApi { api_key_env, .. } => {
2022 self.credential_state_unknown.contains(api_key_env)
2023 }
2024 ModelSource::Proprietary { auth, .. } => match auth {
2025 ProprietaryAuth::ApiKeyEnv { env_var }
2026 | ProprietaryAuth::BearerTokenEnv { env_var } => {
2027 self.credential_state_unknown.contains(env_var)
2028 }
2029 ProprietaryAuth::ChatGptSubscription {} => self.codex_state_unknown,
2030 ProprietaryAuth::OAuth2Pkce { .. } => false,
2031 },
2032 _ => false,
2033 }
2034 }
2035
2036 fn refresh_availability_with(
2037 &mut self,
2038 parslee_oauth_available: bool,
2039 clear_parslee_observations: bool,
2040 authoritative_credentials: bool,
2041 ) {
2042 let models_dir = self.models_dir.clone();
2046 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2051 let mlx_vlm_cli_present = crate::backend::mlx_vlm_cli::is_available();
2052 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2053 #[allow(unused_variables)]
2054 let mlx_vlm_cli_present = false;
2055 if clear_parslee_observations {
2060 crate::openrouter::clear_gateway_unconfigured();
2061 crate::parslee_credential::clear_credential_rejected();
2065 }
2066
2067 let mut credential_envs: std::collections::BTreeSet<String> = Default::default();
2085 let mut needs_openrouter = false;
2086 let mut needs_codex = false;
2087 for m in self.models.values() {
2088 match &m.source {
2089 ModelSource::RemoteApi {
2090 protocol: crate::schema::ApiProtocol::OpenRouter,
2091 ..
2092 } => needs_openrouter = true,
2093 ModelSource::RemoteApi { api_key_env, .. } => {
2094 credential_envs.insert(api_key_env.clone());
2095 }
2096 ModelSource::Proprietary { auth, .. } => match auth {
2097 ProprietaryAuth::ApiKeyEnv { env_var }
2098 | ProprietaryAuth::BearerTokenEnv { env_var } => {
2099 credential_envs.insert(env_var.clone());
2100 }
2101 ProprietaryAuth::ChatGptSubscription {} => needs_codex = true,
2102 ProprietaryAuth::OAuth2Pkce { .. } => {}
2103 },
2104 _ => {}
2105 }
2106 }
2107 let mut credential_unknown: std::collections::HashSet<String> =
2108 std::collections::HashSet::new();
2109 self.codex_state_unknown = false;
2116 if authoritative_credentials && needs_codex {
2117 self.record_codex_lookup(car_auth::codex_credential_exists());
2118 }
2119 let credential_available: std::collections::HashMap<String, bool> = credential_envs
2120 .into_iter()
2121 .map(|env| {
2122 let available = if authoritative_credentials {
2123 match credential_presence(&env) {
2127 CredentialPresence::Present => true,
2128 CredentialPresence::Absent => false,
2129 CredentialPresence::Unknown => {
2130 credential_unknown.insert(env.clone());
2131 false
2132 }
2133 }
2134 } else {
2135 environment_credential_available(&env)
2136 };
2137 (env, available)
2138 })
2139 .collect();
2140 self.credential_state_unknown = credential_unknown;
2141 self.openrouter_state_unknown = false;
2144 let openrouter_available = needs_openrouter
2145 && if authoritative_credentials {
2146 let (source, unknown) = crate::openrouter::refresh_credential_source_with_state();
2147 self.openrouter_state_unknown = unknown;
2148 source.is_some()
2149 } else {
2150 crate::openrouter::credential_source().is_some()
2151 };
2152
2153 for m in self.models.values_mut() {
2154 match &m.source {
2155 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2156 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2165 {
2166 let needs_mlx_vlm = m.tags.iter().any(|t| t == "requires-mlx-vlm");
2173
2174 let in_process_loader_present = !mlx_row_needs_in_process_loader(m)
2182 || crate::backend::local::in_process_loader_available();
2183
2184 m.available = if needs_mlx_vlm {
2185 mlx_vlm_cli_present
2186 } else if m.tags.contains(&"speech".to_string()) {
2187 speech_mlx_available()
2188 } else {
2189 let mlx_dir = models_dir.join(&m.name);
2198 in_process_loader_present
2199 && (mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty())
2200 };
2201 }
2202 #[cfg(not(all(
2203 target_os = "macos",
2204 target_arch = "aarch64",
2205 not(car_skip_mlx)
2206 )))]
2207 {
2208 let _ = hf_repo; m.available = false;
2210 }
2211 }
2212 ModelSource::Local {
2213 hf_repo: local_repo,
2214 ..
2215 } => {
2216 let local_path = models_dir.join(&m.name).join("model.gguf");
2217 #[cfg(not(all(
2232 target_os = "macos",
2233 target_arch = "aarch64",
2234 not(car_skip_mlx)
2235 )))]
2236 {
2237 m.available = local_path.exists() || !local_repo.is_empty();
2238 }
2239 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2244 {
2245 let _ = local_repo;
2246 m.available = local_path.exists();
2247 }
2248 }
2249 ModelSource::WhisperCpp { .. } => {
2250 m.available = true;
2255 }
2256 ModelSource::WindowsSpeech {} => {
2257 #[cfg(target_os = "windows")]
2260 {
2261 m.available = true;
2262 }
2263 #[cfg(not(target_os = "windows"))]
2264 {
2265 m.available = false;
2266 }
2267 }
2268 ModelSource::RemoteApi {
2269 protocol: crate::schema::ApiProtocol::OpenRouter,
2270 ..
2271 } => {
2272 m.available = openrouter_available;
2273 }
2274 ModelSource::RemoteApi { api_key_env, .. } => {
2275 m.available = credential_available
2278 .get(api_key_env)
2279 .copied()
2280 .unwrap_or(false);
2281 }
2282 ModelSource::CodexCli { .. } => {
2283 m.available = crate::backend::codex_cli::is_available();
2284 }
2285 ModelSource::Ollama { .. } => {
2286 m.available = true;
2288 }
2289 ModelSource::VllmMlx { .. } => {
2290 m.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || m.available;
2294 }
2295 ModelSource::Proprietary { provider, auth, .. } => {
2296 m.available = proprietary_auth_available(
2297 &m.id,
2298 &m.provider,
2299 provider,
2300 auth,
2301 parslee_oauth_available,
2302 &credential_available,
2303 );
2304 }
2305 ModelSource::AppleFoundationModels { .. } => {
2306 #[cfg(any(
2313 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2314 all(target_os = "ios", target_arch = "aarch64")
2315 ))]
2316 {
2317 m.available = crate::backend::foundation_models::is_available();
2318 if crate::backend::foundation_models::supports_parallel_tool_calls()
2331 && !m.capabilities.contains(&ModelCapability::MultiToolCall)
2332 {
2333 m.capabilities.push(ModelCapability::MultiToolCall);
2334 }
2335 if crate::backend::foundation_models::supports_vision()
2344 && !m.capabilities.contains(&ModelCapability::Vision)
2345 {
2346 m.capabilities.push(ModelCapability::Vision);
2347 }
2348 if let Some(window) = crate::backend::foundation_models::context_size() {
2356 m.context_length = window as usize;
2357 }
2358 }
2359 #[cfg(not(any(
2360 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2361 all(target_os = "ios", target_arch = "aarch64")
2362 )))]
2363 {
2364 m.available = false;
2365 }
2366 }
2367 ModelSource::Delegated { .. } => {
2368 m.available = crate::runner::current_inference_runner().is_some();
2373 }
2374 }
2375 m.weights_ready = physical_weights_ready(m, &models_dir);
2376 }
2377 }
2378
2379 pub fn save_user_config(&self) -> Result<(), InferenceError> {
2381 let mut user_models: Vec<ModelSchema> = self
2382 .user_config_ids
2383 .iter()
2384 .filter_map(|id| self.models.get(id))
2385 .cloned()
2386 .map(|mut model| {
2387 model.mark_user_registered();
2390 model
2391 })
2392 .collect();
2393 user_models.sort_by(|a, b| a.id.cmp(&b.id));
2394
2395 for model in &user_models {
2396 crate::catalog_identity::row_digest(model).map_err(|error| {
2397 InferenceError::InferenceFailed(format!(
2398 "refuse to persist model without canonical catalog identity: {error}"
2399 ))
2400 })?;
2401 }
2402
2403 let json = serde_json::to_string_pretty(&user_models)
2404 .map_err(|e| InferenceError::InferenceFailed(format!("serialize: {e}")))?;
2405 std::fs::write(&self.user_config_path, json)?;
2406 Ok(())
2407 }
2408
2409 pub fn load_user_config(&mut self) -> Result<(), InferenceError> {
2411 if !self.user_config_path.exists() {
2412 return Ok(());
2413 }
2414
2415 let json = std::fs::read_to_string(&self.user_config_path)?;
2416 let models: Vec<ModelSchema> = serde_json::from_str(&json)
2417 .map_err(|e| InferenceError::InferenceFailed(format!("parse models.json: {e}")))?;
2418
2419 for m in models {
2420 self.register_user_model(m);
2423 }
2424 Ok(())
2425 }
2426
2427 pub fn models_dir(&self) -> &Path {
2429 &self.models_dir
2430 }
2431
2432 pub async fn ensure_drafter(
2441 &self,
2442 schema: &ModelSchema,
2443 ) -> Result<Option<PathBuf>, InferenceError> {
2444 let ModelSource::ManagedVllmMlx {
2445 draft_hf_repo: Some(repo),
2446 ..
2447 } = &schema.source
2448 else {
2449 return Ok(None);
2450 };
2451 let dir = drafter_dir(&self.models_dir, schema);
2452 if !mlx_layout_complete(false, &dir) {
2453 if never_download_in_scope() {
2454 return Err(refuse_download(schema));
2455 }
2456 std::fs::create_dir_all(&dir)?;
2457 info!(model = %schema.name, repo = %repo, "downloading speculative-decoding drafter");
2458 download_repo_snapshot(repo, &dir, &self.ambient_progress).await?;
2459 if !mlx_layout_complete(false, &dir) {
2460 return Err(InferenceError::DownloadFailed(format!(
2461 "{repo}: drafter fetched but has no config.json or weights"
2462 )));
2463 }
2464 }
2465 Ok(Some(dir))
2466 }
2467
2468 pub fn ready_without_download(&self, id: &str) -> Option<bool> {
2484 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2485 Some(self.weights_ready_without_download(id)? && self.drafter_ready(schema))
2486 }
2487
2488 pub fn acquires_without_download(&self, id: &str) -> Option<bool> {
2496 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2497 match &schema.source {
2498 ModelSource::Local { .. }
2499 | ModelSource::Mlx { .. }
2500 | ModelSource::ManagedVllmMlx { .. } => {
2501 Some(self.reusable_local_dir(schema).is_some() && self.drafter_ready(schema))
2502 }
2503 _ => self.ready_without_download(id),
2504 }
2505 }
2506
2507 fn drafter_ready(&self, schema: &ModelSchema) -> bool {
2510 match &schema.source {
2511 ModelSource::ManagedVllmMlx {
2512 draft_hf_repo: Some(_),
2513 ..
2514 } => mlx_layout_complete(false, &drafter_dir(&self.models_dir, schema)),
2515 _ => true,
2516 }
2517 }
2518
2519 fn weights_ready_without_download(&self, id: &str) -> Option<bool> {
2522 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2523 Some(match &schema.source {
2524 ModelSource::Local { .. } => {
2525 let model_dir = self.models_dir.join(&schema.name);
2526 crate::download::cache_file_usable(&model_dir.join("model.gguf"))
2527 && crate::download::cache_file_usable(&model_dir.join("tokenizer.json"))
2528 }
2529 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2530 let managed_dir = self.models_dir.join(&schema.name);
2531 let managed_ready = mlx_snapshot_complete(schema, &managed_dir)
2532 && crate::download::cache_file_usable(&managed_dir.join("tokenizer.json"));
2533 let snapshot_ready =
2534 latest_huggingface_repo_snapshot(hf_repo).is_some_and(|snapshot| {
2535 mlx_snapshot_complete(schema, &snapshot)
2536 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2537 });
2538 managed_ready || snapshot_ready
2539 }
2540 ModelSource::WindowsSpeech {} => true, ModelSource::WhisperCpp { model } => {
2542 car_whisper::model_cached(model)
2546 }
2547 ModelSource::RemoteApi { .. }
2548 | ModelSource::CodexCli { .. }
2549 | ModelSource::Ollama { .. }
2550 | ModelSource::VllmMlx { .. }
2551 | ModelSource::AppleFoundationModels { .. }
2552 | ModelSource::Proprietary { .. }
2553 | ModelSource::Delegated { .. } => true,
2554 })
2555 }
2556
2557 pub fn existing_local_artifact(&self, id: &str) -> Option<PathBuf> {
2561 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2562 let managed = self.models_dir.join(&schema.name);
2563 if std::fs::symlink_metadata(&managed).is_ok()
2564 && self.weights_ready_without_download(&schema.id) == Some(true)
2565 {
2566 return Some(managed);
2567 }
2568 match &schema.source {
2569 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2570 latest_huggingface_repo_snapshot(hf_repo).filter(|snapshot| {
2571 mlx_snapshot_complete(schema, snapshot)
2572 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2573 })
2574 }
2575 _ => None,
2576 }
2577 }
2578
2579 fn load_builtin_catalog(&mut self) {
2581 for schema in builtin_catalog() {
2582 let id = schema.id.clone();
2583 if self.register_project_model(schema) {
2584 self.builtin_model_ids.insert(id);
2585 }
2586 }
2587 }
2588}
2589
2590fn synthesize_local_schema(name: &str, dir: &Path) -> Option<ModelSchema> {
2600 let lower = name.to_ascii_lowercase();
2601
2602 const NON_TEXT_HINTS: &[&str] = &[
2605 "vad",
2606 "whisper",
2607 "parakeet",
2608 "kokoro",
2609 "tts",
2610 "stt",
2611 "flux",
2612 "ltx",
2613 "yume",
2614 "sd-",
2615 "stable-diffusion",
2616 "wan",
2617 "mochi",
2618 "sana",
2619 "diffusion",
2620 ];
2621 if NON_TEXT_HINTS.iter().any(|h| lower.contains(h)) {
2622 return None;
2623 }
2624
2625 let capabilities: Vec<ModelCapability> =
2628 if lower.contains("embedding") || lower.contains("embed") {
2629 vec![ModelCapability::Embed]
2630 } else if lower.contains("reranker") || lower.contains("rerank") {
2631 vec![ModelCapability::Rerank]
2632 } else {
2633 vec![
2634 ModelCapability::Generate,
2635 ModelCapability::Code,
2636 ModelCapability::Reasoning,
2637 ]
2638 };
2639
2640 let config_path = dir.join("config.json");
2642 let has_safetensors =
2643 dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists();
2644
2645 let (source, context_length, quantization) = if config_path.exists() && has_safetensors {
2646 let cfg: serde_json::Value = std::fs::read_to_string(&config_path)
2648 .ok()
2649 .and_then(|s| serde_json::from_str(&s).ok())?;
2650 let model_type = cfg
2651 .get("model_type")
2652 .and_then(|v| v.as_str())
2653 .unwrap_or("")
2654 .to_ascii_lowercase();
2655 const KNOWN_LLM_TYPES: &[&str] = &[
2662 "qwen",
2663 "qwen2",
2664 "qwen3",
2665 "qwen3_moe",
2666 "llama",
2667 "mistral",
2668 "mixtral",
2669 "gemma",
2670 "gemma2",
2671 "gemma3",
2672 "gemma4_unified",
2673 "gemma4_unified_text",
2674 "phi",
2675 "phi3",
2676 "phimoe",
2677 "starcoder2",
2678 "deepseek",
2679 "deepseek_v2",
2680 "internlm2",
2681 "cohere",
2682 "olmo",
2683 ];
2684 let recognized = KNOWN_LLM_TYPES.iter().any(|t| model_type == *t)
2685 || crate::backend::local::has_native_backend(&model_type);
2686 if !recognized {
2687 return None;
2688 }
2689 let ctx = cfg
2690 .get("max_position_embeddings")
2691 .and_then(|v| v.as_u64())
2692 .unwrap_or(32_768) as usize;
2693 let quant = cfg
2699 .get("quantization")
2700 .filter(|q| q.is_object())
2701 .and_then(|q| {
2702 let bits = q
2703 .get("bits")
2704 .and_then(|b| b.as_u64())
2705 .and_then(|b| u8::try_from(b).ok());
2706 let group_size = q
2707 .get("group_size")
2708 .and_then(|g| g.as_u64())
2709 .and_then(|g| u32::try_from(g).ok());
2710 let mode = q.get("mode").and_then(|m| m.as_str());
2711 crate::schema::Quantization::from_mlx_config(bits, group_size, mode)
2712 });
2713 (
2714 serde_json::json!({ "type": "mlx", "hf_repo": "" }),
2715 ctx,
2716 quant,
2717 )
2718 } else {
2719 let gguf = std::fs::read_dir(dir).ok().and_then(|rd| {
2720 rd.flatten().map(|e| e.path()).find(|p| {
2721 p.extension()
2722 .and_then(|x| x.to_str())
2723 .is_some_and(|x| x.eq_ignore_ascii_case("gguf"))
2724 })
2725 })?;
2726 let filename = gguf
2728 .file_name()
2729 .and_then(|n| n.to_str())
2730 .unwrap_or("model.gguf")
2731 .to_string();
2732 let quant = crate::schema::Quantization::from_gguf_filename(&filename);
2736 (
2737 serde_json::json!({
2738 "type": "local",
2739 "hf_repo": "",
2740 "hf_filename": filename,
2741 "tokenizer_repo": "",
2742 }),
2743 4_096,
2744 quant,
2745 )
2746 };
2747
2748 let id = format!("local/{}", lower.replace(['/', ' '], "-"));
2749 serde_json::from_value(serde_json::json!({
2750 "id": id,
2751 "name": name,
2752 "provider": "local",
2753 "family": "local",
2754 "capabilities": capabilities,
2755 "context_length": context_length,
2756 "quantization": quantization,
2757 "source": source,
2758 "tags": ["auto-discovered"],
2759 "trust_tier": "community",
2760 }))
2761 .ok()
2762}
2763
2764#[allow(dead_code)]
2783fn speech_mlx_available() -> bool {
2784 let runtime_root = speech_runtime_root();
2788 crate::managed_venv::venv_program(&runtime_root, "mlx_audio.stt.generate").exists()
2789 || crate::managed_venv::venv_program(&runtime_root, "mlx_audio.tts.generate").exists()
2790}
2791
2792#[allow(dead_code)]
2794fn speech_runtime_root() -> PathBuf {
2795 if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
2796 if !path.trim().is_empty() {
2797 return PathBuf::from(path);
2798 }
2799 }
2800 std::env::var_os("HOME")
2801 .or_else(|| std::env::var_os("USERPROFILE"))
2802 .map(PathBuf::from)
2803 .unwrap_or_else(|| PathBuf::from("."))
2804 .join(".car")
2805 .join("speech-runtime")
2806}
2807
2808#[derive(Debug, Clone, Serialize, Deserialize)]
2810pub struct ModelInfo {
2811 pub id: String,
2812 pub name: String,
2813 pub provider: String,
2814 pub capabilities: Vec<ModelCapability>,
2815 pub param_count: String,
2816 pub size_mb: u64,
2817 pub context_length: usize,
2818 pub available: bool,
2819 pub is_local: bool,
2820 #[serde(default)]
2824 pub operator_managed_external_runtime: bool,
2825 #[serde(default)]
2833 pub weights_ready: bool,
2834 #[serde(default)]
2847 pub downloads_weights: bool,
2848 #[serde(default)]
2852 pub max_output_tokens: Option<usize>,
2853 #[serde(default)]
2857 pub public_benchmarks: Vec<crate::schema::BenchmarkScore>,
2858 #[serde(default)]
2873 pub cost: crate::schema::CostModel,
2874 #[serde(default = "default_true")]
2875 pub car_enabled: bool,
2876 #[serde(default)]
2877 pub can_remove: bool,
2878 #[serde(default)]
2879 pub in_use: bool,
2880 #[serde(default)]
2881 pub management_evidence: Option<String>,
2882 #[serde(default)]
2893 pub fit: crate::recommend::ModelFitStatus,
2894 #[serde(default)]
2897 pub estimated_peak_mb: Option<u64>,
2898 #[serde(default = "default_true")]
2907 pub platform_compatible: bool,
2908 #[serde(default)]
2912 pub deprecated: bool,
2913 #[serde(default)]
2951 pub credential_required: Option<String>,
2952 #[serde(default)]
2954 pub unavailable_reason: Option<String>,
2955 #[serde(default)]
2961 pub family: Option<String>,
2962 #[serde(default)]
2966 pub version: Option<String>,
2967 #[serde(default)]
2973 pub source_kind: String,
2974}
2975
2976pub fn model_unavailable_reason(info: &ModelInfo) -> Option<&'static str> {
2979 (!info.available && info.provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER))
2980 .then_some(crate::schema::OPENAI_CODEX_SIGN_IN_HINT)
2981}
2982
2983fn default_true() -> bool {
2984 true
2985}
2986
2987impl ModelInfo {
2988 pub fn with_fit(mut self, fit: crate::recommend::ModelFit) -> Self {
2992 self.fit = fit.fit;
2993 self.estimated_peak_mb = fit.estimated_peak_mb;
2994 self.platform_compatible = fit.platform_compatible;
2995 self
2996 }
2997}
2998
2999impl From<&ModelSchema> for ModelInfo {
3000 fn from(s: &ModelSchema) -> Self {
3001 let mut info = ModelInfo {
3002 id: s.id.clone(),
3003 name: s.name.clone(),
3004 provider: s.provider.clone(),
3005 capabilities: s.capabilities.clone(),
3006 param_count: s.param_count.clone(),
3007 size_mb: s.size_mb(),
3008 context_length: s.context_length,
3009 available: s.available_now(),
3010 is_local: s.is_local(),
3011 operator_managed_external_runtime: matches!(s.source, ModelSource::VllmMlx { .. }),
3012 weights_ready: s.weights_ready,
3013 downloads_weights: s.downloads_weights(),
3014 max_output_tokens: s.max_output_tokens,
3015 public_benchmarks: s.public_benchmarks.clone(),
3016 cost: s.cost.clone(),
3021 car_enabled: true,
3022 can_remove: false,
3023 in_use: false,
3024 management_evidence: None,
3025 fit: crate::recommend::ModelFitStatus::Unknown,
3030 estimated_peak_mb: None,
3031 platform_compatible: true,
3032 credential_required: s.credential_required(),
3033 unavailable_reason: None,
3034 deprecated: s.deprecated,
3035 family: s.is_local().then(|| s.family.clone()),
3038 version: s.is_local().then(|| s.version.clone()),
3039 source_kind: s.source.kind().to_string(),
3040 };
3041 info.unavailable_reason = model_unavailable_reason(&info).map(str::to_owned);
3042 info
3043 }
3044}
3045
3046fn emit_file(sink: &ProgressSink, name: &str, index: u32, size_mb: u64) {
3051 sink.emit(DownloadEvent::FileStarted {
3052 filename: name.to_string(),
3053 index,
3054 total_files: 0,
3055 size_mb,
3056 });
3057}
3058
3059async fn download_repo_snapshot(
3068 repo: &str,
3069 model_dir: &Path,
3070 sink: &ProgressSink,
3071) -> Result<(), InferenceError> {
3072 refuse_fetch_when_never_download(repo)?;
3073 #[derive(serde::Deserialize)]
3074 struct RepoInfo {
3075 siblings: Vec<Sibling>,
3076 }
3077 #[derive(serde::Deserialize)]
3078 struct Sibling {
3079 rfilename: String,
3080 }
3081 let url = format!("{}/api/models/{repo}", crate::hf_cache::endpoint());
3082 let info: RepoInfo = crate::tls_client::model_download_client()
3087 .get(&url)
3088 .send()
3089 .await
3090 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
3091 .error_for_status()
3092 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
3093 .json()
3094 .await
3095 .map_err(|e| InferenceError::DownloadFailed(format!("parse {repo} file list: {e}")))?;
3096
3097 let files: Vec<String> = info
3098 .siblings
3099 .into_iter()
3100 .map(|s| s.rfilename)
3101 .filter(|f| !f.starts_with('.') && !f.to_ascii_lowercase().ends_with(".md"))
3102 .collect();
3103 if files.is_empty() {
3104 return Err(InferenceError::DownloadFailed(format!(
3105 "{repo}: repo lists no downloadable files"
3106 )));
3107 }
3108
3109 let total = files.len() as u32;
3110 for (i, fname) in files.iter().enumerate() {
3111 let dest = model_dir.join(fname);
3112 if crate::download::cache_file_usable(&dest) {
3113 continue;
3114 }
3115 if let Some(parent) = dest.parent() {
3116 std::fs::create_dir_all(parent)?;
3117 }
3118 sink.emit(DownloadEvent::FileStarted {
3119 filename: fname.clone(),
3120 index: (i + 1) as u32,
3121 total_files: total,
3122 size_mb: 0,
3123 });
3124 download_file(repo, fname, &dest).await?;
3125 sink.emit(DownloadEvent::FileCompleted {
3126 filename: fname.clone(),
3127 });
3128 }
3129 Ok(())
3130}
3131
3132pub(crate) const TOKENIZER_FILENAMES: &[&str] = &[
3143 "tokenizer.json",
3144 "vocab.json",
3145 "merges.txt",
3146 "tokenizer.model",
3147 "tokenizer.vocab",
3148 "vocab.txt",
3149];
3150
3151async fn download_tokenizer_assets(hf_repo: &str, model_dir: &Path, sink: &ProgressSink) {
3171 if TOKENIZER_FILENAMES
3173 .iter()
3174 .any(|f| crate::download::cache_file_usable(&model_dir.join(f)))
3175 {
3176 return;
3177 }
3178 emit_file(sink, "tokenizer", 0, 0);
3179 let mut fetched: Vec<&str> = Vec::new();
3180 for name in TOKENIZER_FILENAMES {
3181 let dest = model_dir.join(name);
3182 if crate::download::cache_file_usable(&dest) {
3183 continue;
3184 }
3185 if download_file(hf_repo, name, &dest).await.is_ok() {
3186 fetched.push(name);
3187 }
3188 }
3189 if fetched.is_empty() {
3190 tracing::debug!(
3192 repo = %hf_repo,
3193 "no tokenizer assets in this repo; continuing (the backend may not need one)"
3194 );
3195 } else {
3196 tracing::debug!(repo = %hf_repo, files = ?fetched, "fetched tokenizer assets");
3197 }
3198}
3199
3200async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
3201 if crate::download::cache_file_usable(dest) {
3205 return Ok(());
3206 }
3207 refuse_fetch_when_never_download(&format!("{repo}/{filename}"))?;
3208
3209 let api = crate::hf_cache::api()
3210 .build()
3211 .map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
3212
3213 let already_cached = hf_hub::Cache::new(crate::hf_cache::hub_dir())
3216 .repo(hf_hub::Repo::model(repo.to_string()))
3217 .get(filename)
3218 .is_some();
3219 let repo_id = repo;
3220 let repo = api.model(repo.to_string());
3221 let path = repo
3222 .get(filename)
3223 .await
3224 .map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
3225 if !already_cached {
3226 crate::retire::note_fetched(repo_id, filename, crate::retire::snapshot_revision(&path));
3227 }
3228
3229 install_fetched_file(&path, dest)
3230}
3231
3232fn install_fetched_file(src: &Path, dest: &Path) -> Result<(), InferenceError> {
3234 if crate::download::cache_file_usable(dest) {
3235 return Ok(());
3236 }
3237
3238 match std::fs::symlink_metadata(dest) {
3239 Ok(_) => std::fs::remove_file(dest).map_err(|e| {
3240 InferenceError::DownloadFailed(format!(
3241 "remove unusable destination {}: {e}",
3242 dest.display()
3243 ))
3244 })?,
3245 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3246 Err(error) => {
3247 return Err(InferenceError::DownloadFailed(format!(
3248 "inspect destination {}: {error}",
3249 dest.display()
3250 )));
3251 }
3252 }
3253
3254 #[cfg(unix)]
3258 {
3259 if std::os::unix::fs::symlink(src, dest).is_ok() {
3260 return Ok(());
3261 }
3262 }
3263
3264 static INSTALL_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3265 let sequence = INSTALL_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3266 let file_name = dest
3267 .file_name()
3268 .and_then(|name| name.to_str())
3269 .unwrap_or("download");
3270 let temp = dest.with_file_name(format!(
3271 ".{file_name}.car-install-{}-{sequence}.tmp",
3272 std::process::id()
3273 ));
3274 let _ = std::fs::remove_file(&temp);
3275 std::fs::copy(src, &temp).map_err(|error| {
3276 InferenceError::DownloadFailed(format!(
3277 "copy to temporary destination {}: {error}",
3278 temp.display()
3279 ))
3280 })?;
3281 if let Err(error) = std::fs::rename(&temp, dest) {
3282 let _ = std::fs::remove_file(&temp);
3283 return Err(InferenceError::DownloadFailed(format!(
3284 "publish downloaded file at {}: {error}",
3285 dest.display()
3286 )));
3287 }
3288 Ok(())
3289}
3290
3291pub(crate) const FLUX_AUXILIARY: (&str, &str, &str, &str) = (
3296 "mlx-community/Flux-1.lite-8B-MLX-Q4",
3297 "Flux-1.lite-8B-MLX-Q4",
3298 "Freepik/flux.1-lite-8B",
3299 "tokenizer_2/tokenizer.json",
3300);
3301
3302pub(crate) const LTX_TEXT_ENCODER: &str = "mlx-community/gemma-3-12b-it-4bit";
3308
3309pub(crate) fn uses_ltx_text_encoder(schema: &ModelSchema) -> bool {
3312 matches!(schema.source, ModelSource::Mlx { .. })
3313 && schema.has_capability(ModelCapability::VideoGeneration)
3314}
3315
3316pub(crate) fn uses_flux_auxiliary(model_name: &str, hf_repo: &str) -> bool {
3317 hf_repo == FLUX_AUXILIARY.0 || model_name == FLUX_AUXILIARY.1
3318}
3319
3320fn auxiliary_mlx_files_missing(model_name: &str, hf_repo: &str, model_dir: &Path) -> bool {
3321 uses_flux_auxiliary(model_name, hf_repo)
3322 && !crate::download::cache_file_usable(
3323 &model_dir.join("tokenizer_2").join("tokenizer.json"),
3324 )
3325}
3326
3327fn chat_template_missing(model_dir: &Path) -> bool {
3337 let tok_cfg = model_dir.join("tokenizer_config.json");
3338 if !crate::download::cache_file_usable(&tok_cfg)
3339 || crate::download::cache_file_usable(&model_dir.join("chat_template.jinja"))
3340 {
3341 return false;
3342 }
3343 std::fs::read_to_string(&tok_cfg)
3344 .ok()
3345 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
3346 .is_some_and(|cfg| cfg.get("chat_template").is_none())
3347}
3348
3349async fn ensure_auxiliary_mlx_files(
3350 model_name: &str,
3351 hf_repo: &str,
3352 model_dir: &Path,
3353) -> Result<(), InferenceError> {
3354 if chat_template_missing(model_dir) {
3358 if let Err(error) = download_file(
3359 hf_repo,
3360 "chat_template.jinja",
3361 &model_dir.join("chat_template.jinja"),
3362 )
3363 .await
3364 {
3365 tracing::debug!(repo = %hf_repo, %error, "no chat_template.jinja in this repo");
3366 }
3367 }
3368 if auxiliary_mlx_files_missing(model_name, hf_repo, model_dir) {
3369 let t5_tokenizer_path = model_dir.join("tokenizer_2").join("tokenizer.json");
3370 std::fs::create_dir_all(
3371 t5_tokenizer_path
3372 .parent()
3373 .ok_or_else(|| InferenceError::InferenceFailed("invalid tokenizer path".into()))?,
3374 )?;
3375 info!(
3376 path = %t5_tokenizer_path.display(),
3377 "downloading missing Flux tokenizer_2/tokenizer.json from base model"
3378 );
3379 download_file(FLUX_AUXILIARY.2, FLUX_AUXILIARY.3, &t5_tokenizer_path).await?;
3380 }
3381 Ok(())
3382}
3383
3384fn mlx_auxiliary_ready_without_download(model_name: &str, model_dir: &Path) -> bool {
3385 if model_name == "Flux-1.lite-8B-MLX-Q4" {
3386 return crate::download::cache_file_usable(
3387 &model_dir.join("tokenizer_2").join("tokenizer.json"),
3388 );
3389 }
3390 true
3391}
3392
3393fn physical_weights_ready(schema: &ModelSchema, models_dir: &Path) -> bool {
3396 physical_weights_ready_with_huggingface_hub(schema, models_dir, None)
3397}
3398
3399pub(crate) fn physical_weights_ready_with_huggingface_hub(
3400 schema: &ModelSchema,
3401 models_dir: &Path,
3402 huggingface_hub_root: Option<&Path>,
3403) -> bool {
3404 match &schema.source {
3405 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
3406 let managed_dir = models_dir.join(&schema.name);
3407 if mlx_snapshot_complete(schema, &managed_dir) {
3408 return true;
3409 }
3410 let shared_snapshot = match huggingface_hub_root {
3411 Some(root) => latest_huggingface_repo_snapshot_in(&crate::hf_cache::repo_dir_in(
3412 root, hf_repo,
3413 )),
3414 None => latest_huggingface_repo_snapshot(hf_repo),
3415 };
3416 shared_snapshot
3417 .as_deref()
3418 .is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3419 }
3420 ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
3421 ModelSource::Local { .. } => {
3422 crate::download::cache_file_usable(&models_dir.join(&schema.name).join("model.gguf"))
3423 }
3424 ModelSource::WindowsSpeech {}
3428 | ModelSource::AppleFoundationModels { .. }
3429 | ModelSource::VllmMlx { .. }
3430 | ModelSource::Ollama { .. }
3431 | ModelSource::RemoteApi { .. }
3432 | ModelSource::CodexCli { .. }
3433 | ModelSource::Proprietary { .. }
3434 | ModelSource::Delegated { .. } => true,
3435 }
3436}
3437
3438#[cfg(test)]
3439fn mlx_weights_ready_at(
3440 schema: &ModelSchema,
3441 managed_dir: &Path,
3442 shared_snapshot: Option<&Path>,
3443) -> bool {
3444 mlx_snapshot_complete(schema, managed_dir)
3445 || shared_snapshot.is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3446}
3447
3448fn mlx_snapshot_complete(schema: &ModelSchema, dir: &Path) -> bool {
3455 mlx_layout_complete(mlx_schema_is_diffusers(schema), dir)
3456 && mlx_auxiliary_ready_without_download(&schema.name, dir)
3457}
3458
3459fn mlx_schema_is_diffusers(schema: &ModelSchema) -> bool {
3463 schema.capabilities.iter().any(|capability| {
3464 matches!(
3465 capability,
3466 ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
3467 )
3468 })
3469}
3470
3471tokio::task_local! {
3472 static NEVER_DOWNLOAD: ();
3474}
3475
3476pub(crate) fn never_download_scope<F: std::future::Future>(
3494 never: bool,
3495 future: F,
3496) -> impl std::future::Future<Output = F::Output> {
3497 let future = Box::pin(future);
3498 async move {
3499 if never {
3500 NEVER_DOWNLOAD.scope((), future).await
3501 } else {
3502 future.await
3503 }
3504 }
3505}
3506
3507pub(crate) fn never_download_sync_scope<R>(never: bool, f: impl FnOnce() -> R) -> R {
3511 if never {
3512 NEVER_DOWNLOAD.sync_scope((), f)
3513 } else {
3514 f()
3515 }
3516}
3517
3518pub(crate) fn never_download_in_scope() -> bool {
3520 NEVER_DOWNLOAD.try_with(|_| ()).is_ok()
3521}
3522
3523#[cfg(test)]
3526pub(crate) static REFUSED_DOWNLOADS: std::sync::atomic::AtomicUsize =
3527 std::sync::atomic::AtomicUsize::new(0);
3528
3529pub(crate) fn not_installed_error(schema: &ModelSchema) -> InferenceError {
3532 InferenceError::NoEligibleModel {
3533 excluded_models: format!(
3534 "{name} is not installed, and this request never downloads a model. \
3535 Install it with `car models pull {name}`, or connect a provider's API key \
3536 so a model that is ready can be used",
3537 name = schema.name
3538 ),
3539 }
3540}
3541
3542fn refuse_download(schema: &ModelSchema) -> InferenceError {
3544 #[cfg(test)]
3545 REFUSED_DOWNLOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3546 tracing::info!(model = %schema.name, "refused a model download for a never-download request");
3547 not_installed_error(schema)
3548}
3549
3550pub(crate) fn refuse_fetch_when_never_download(what: &str) -> Result<(), InferenceError> {
3553 if !never_download_in_scope() {
3554 return Ok(());
3555 }
3556 #[cfg(test)]
3557 REFUSED_DOWNLOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3558 Err(InferenceError::NoEligibleModel {
3559 excluded_models: format!(
3560 "{what} would have to be downloaded, and this request never downloads a model"
3561 ),
3562 })
3563}
3564
3565fn drafter_dir(models_dir: &Path, schema: &ModelSchema) -> PathBuf {
3567 models_dir.join(&schema.name).join("mtp-drafter")
3568}
3569
3570fn mlx_layout_complete(is_diffusers: bool, dir: &Path) -> bool {
3579 (is_diffusers || crate::download::cache_file_usable(&dir.join("config.json")))
3580 && mlx_dir_has_weights(dir)
3581}
3582
3583#[allow(dead_code)]
3603fn mlx_row_needs_in_process_loader(schema: &ModelSchema) -> bool {
3604 use crate::schema::ModelCapability as C;
3605 let tagged = |t: &str| schema.tags.iter().any(|x| x == t);
3606 if tagged("speech") || tagged("requires-mlx-vlm") {
3607 return false;
3608 }
3609 if schema
3610 .capabilities
3611 .iter()
3612 .any(|c| matches!(c, C::ImageGeneration | C::VideoGeneration))
3613 {
3614 return false;
3615 }
3616 matches!(schema.source, ModelSource::Mlx { .. })
3617}
3618
3619pub(crate) fn mlx_dir_has_weights(dir: &Path) -> bool {
3641 let index = dir.join("model.safetensors.index.json");
3645 if index.is_file() {
3646 return sharded_weight_files(&index).is_some_and(|required| {
3647 !required.is_empty()
3648 && required
3649 .iter()
3650 .all(|shard| crate::download::cache_file_usable(&dir.join(shard)))
3651 });
3652 }
3653 mlx_dir_has_weights_depth(dir, 0)
3654}
3655
3656pub(crate) fn missing_weight_shards(dir: &Path) -> Vec<String> {
3668 let index = dir.join("model.safetensors.index.json");
3669 if !index.is_file() {
3670 return Vec::new();
3671 }
3672 let Some(required) = sharded_weight_files(&index) else {
3673 return Vec::new();
3676 };
3677 required
3678 .into_iter()
3679 .filter(|shard| !dir.join(shard).exists())
3680 .collect()
3681}
3682
3683fn sharded_weight_files(index: &Path) -> Option<Vec<String>> {
3690 let raw = std::fs::read_to_string(index).ok()?;
3691 let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
3692 let map = parsed.get("weight_map")?.as_object()?;
3693 let mut files: Vec<String> = map
3694 .values()
3695 .filter_map(|v| v.as_str().map(str::to_string))
3696 .collect();
3697 files.sort();
3698 files.dedup();
3699 Some(files)
3700}
3701
3702fn mlx_dir_has_weights_depth(dir: &Path, depth: usize) -> bool {
3713 if depth > 4 {
3714 return false;
3715 }
3716 let Ok(rd) = std::fs::read_dir(dir) else {
3717 return false;
3718 };
3719 rd.flatten().any(|e| {
3720 let p = e.path();
3721 let is_symlink = std::fs::symlink_metadata(&p)
3722 .map(|m| m.file_type().is_symlink())
3723 .unwrap_or(true);
3724 if p.is_dir() {
3725 !is_symlink && mlx_dir_has_weights_depth(&p, depth + 1)
3726 } else {
3727 p.extension().and_then(|x| x.to_str()) == Some("safetensors")
3731 && crate::download::cache_file_usable(&p)
3732 }
3733 })
3734}
3735
3736#[allow(dead_code)] fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
3738 latest_huggingface_repo_snapshot(repo_id).is_some()
3739}
3740
3741fn resolve_huggingface_ref_snapshot(repo_dir: &Path, name: &str) -> Option<PathBuf> {
3742 let sha = std::fs::read_to_string(repo_dir.join("refs").join(name))
3743 .ok()?
3744 .trim()
3745 .to_string();
3746 if sha.is_empty() {
3747 return None;
3748 }
3749
3750 let snapshot = repo_dir.join("snapshots").join(sha);
3751 if snapshot_looks_ready(&snapshot) {
3752 Some(snapshot)
3753 } else {
3754 None
3755 }
3756}
3757
3758pub(crate) fn latest_huggingface_repo_snapshot(repo_id: &str) -> Option<PathBuf> {
3759 let repo_dir = crate::hf_cache::repo_dir(repo_id);
3760 latest_huggingface_repo_snapshot_in(&repo_dir)
3761}
3762
3763pub(crate) fn latest_huggingface_repo_snapshot_in(repo_dir: &Path) -> Option<PathBuf> {
3764 if let Some(snapshot) = resolve_huggingface_ref_snapshot(repo_dir, "main") {
3765 return Some(snapshot);
3766 }
3767
3768 let snapshots = repo_dir.join("snapshots");
3769 let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(snapshots)
3770 .ok()?
3771 .filter_map(Result::ok)
3772 .map(|e| e.path())
3773 .filter(|p| p.is_dir() && snapshot_looks_ready(p))
3774 .map(|path| {
3775 let modified = path
3776 .metadata()
3777 .and_then(|metadata| metadata.modified())
3778 .unwrap_or(SystemTime::UNIX_EPOCH);
3779 (modified, path)
3780 })
3781 .collect();
3782 candidates.sort();
3783 candidates.pop().map(|(_, path)| path)
3784}
3785
3786fn snapshot_looks_ready(path: &Path) -> bool {
3787 if path.join("config.json").exists() || path.join("model_index.json").exists() {
3788 return true;
3789 }
3790 snapshot_contains_ext(path, "safetensors")
3791}
3792
3793fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
3794 let Ok(entries) = std::fs::read_dir(root) else {
3795 return false;
3796 };
3797 entries.filter_map(Result::ok).any(|entry| {
3798 let path = entry.path();
3799 if path.is_dir() {
3800 snapshot_contains_ext(&path, ext)
3801 } else {
3802 let ext_matches = path
3803 .extension()
3804 .and_then(|value| value.to_str())
3805 .map(|value| value.eq_ignore_ascii_case(ext))
3806 .unwrap_or(false);
3807 ext_matches && crate::download::cache_file_usable(&path)
3811 }
3812 })
3813}
3814
3815const BUILTIN_CATALOG_JSON: &str = include_str!("builtin_catalog.json");
3824
3825static BUILTIN_CATALOG: std::sync::LazyLock<Vec<ModelSchema>> = std::sync::LazyLock::new(|| {
3826 serde_json::from_str(BUILTIN_CATALOG_JSON)
3827 .expect("builtin_catalog.json failed to parse — fix the JSON, not this code")
3828});
3829
3830pub(crate) fn builtin_catalog() -> Vec<ModelSchema> {
3831 let mut catalog = BUILTIN_CATALOG.clone();
3832 catalog.extend(crate::openrouter::builtin_schemas());
3833 catalog
3834}
3835
3836pub const EXTRA_CREDENTIAL_ENV_NAMES: &[&str] = &[
3839 "OPENAI_API_KEY",
3840 "OPENROUTER_API_KEY",
3841 "ANTHROPIC_API_KEY",
3842 "ANTHROPIC_AUTH_TOKEN",
3843 "GEMINI_API_KEY",
3844 "GOOGLE_API_KEY",
3845 "ELEVENLABS_API_KEY",
3846 "GITHUB_TOKEN",
3847 "GH_TOKEN",
3848 "AWS_ACCESS_KEY_ID",
3849 "AWS_SECRET_ACCESS_KEY",
3850 "AWS_SESSION_TOKEN",
3851 "TAVILY_API_KEY",
3852 "HF_TOKEN",
3853 "CODEX_API_KEY",
3854 "PARSLEE_ORG_GRANTER_SEED",
3855 "PARSLEE_ORG_GRANTER_SEEDS",
3856];
3857
3858const CREDENTIAL_NAME_SUFFIXES: &[&str] = &[
3859 "_API_KEY",
3860 "_APIKEY",
3861 "_TOKEN",
3862 "_SECRET",
3863 "_PASSWORD",
3864 "_PASSWD",
3865 "_ACCESS_KEY",
3866 "_PRIVATE_KEY",
3867 "_CREDENTIALS",
3868];
3869
3870pub fn is_credential_env_name(name: &str) -> bool {
3876 let upper = name.to_ascii_uppercase();
3877 EXTRA_CREDENTIAL_ENV_NAMES.contains(&upper.as_str())
3878 || CREDENTIAL_NAME_SUFFIXES
3879 .iter()
3880 .any(|suffix| upper.ends_with(suffix))
3881 || builtin_credential_env_names().contains(name)
3882}
3883
3884pub fn builtin_credential_env_names() -> std::collections::BTreeSet<String> {
3890 let mut names = std::collections::BTreeSet::new();
3891 for model in builtin_catalog() {
3892 match model.source {
3893 ModelSource::RemoteApi {
3894 api_key_env,
3895 api_key_envs,
3896 ..
3897 } => {
3898 names.insert(api_key_env);
3899 names.extend(api_key_envs);
3900 }
3901 ModelSource::Proprietary { auth, .. } => match auth {
3902 ProprietaryAuth::ApiKeyEnv { env_var }
3903 | ProprietaryAuth::BearerTokenEnv { env_var } => {
3904 names.insert(env_var);
3905 }
3906 ProprietaryAuth::OAuth2Pkce { .. } | ProprietaryAuth::ChatGptSubscription {} => {}
3907 },
3908 ModelSource::Local { .. }
3909 | ModelSource::CodexCli { .. }
3910 | ModelSource::Ollama { .. }
3911 | ModelSource::Mlx { .. }
3912 | ModelSource::WhisperCpp { .. }
3913 | ModelSource::WindowsSpeech {}
3914 | ModelSource::VllmMlx { .. }
3915 | ModelSource::ManagedVllmMlx { .. }
3916 | ModelSource::AppleFoundationModels { .. }
3917 | ModelSource::Delegated { .. } => {}
3918 }
3919 }
3920 names
3921}
3922
3923pub fn is_builtin_subscription_billed(model_id: &str) -> bool {
3931 let latest_id = implicit_latest_id(model_id);
3932 builtin_catalog().into_iter().any(|model| {
3933 (model.id == model_id || latest_id.as_deref() == Some(model.id.as_str()))
3934 && model.is_subscription_billed()
3935 })
3936}
3937
3938#[doc(hidden)]
3941pub fn builtin_catalog_with_huggingface_hub_for_testing(
3942 models_dir: &Path,
3943 huggingface_hub_root: &Path,
3944) -> Vec<ModelSchema> {
3945 let mut catalog = builtin_catalog();
3946 for schema in &mut catalog {
3947 schema.weights_ready = physical_weights_ready_with_huggingface_hub(
3948 schema,
3949 models_dir,
3950 Some(huggingface_hub_root),
3951 );
3952 }
3953 catalog
3954}
3955
3956pub fn implicit_latest_id(model: &str) -> Option<String> {
3960 (!model.contains(':')).then(|| format!("{model}:latest"))
3961}
3962
3963pub fn find_model_row<'a>(rows: &'a [ModelSchema], model: &str) -> Option<&'a ModelSchema> {
3970 rows.iter()
3971 .find(|row| row.id == model)
3972 .or_else(|| find_named(model, rows.iter(), |row| &row.name))
3973 .or_else(|| {
3974 let latest = implicit_latest_id(model)?;
3975 rows.iter().find(|row| row.id == latest)
3976 })
3977}
3978
3979pub(crate) fn find_named<T>(
3985 name: &str,
3986 mut items: impl Iterator<Item = T> + Clone,
3987 name_of: impl Fn(&T) -> &str,
3988) -> Option<T> {
3989 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3990 if !name.to_ascii_lowercase().ends_with("-mlx") {
3991 let mlx = format!("{name}-MLX");
3992 if let Some(mlx_variant) = items
3993 .clone()
3994 .find(|item| name_of(item).eq_ignore_ascii_case(&mlx))
3995 {
3996 return Some(mlx_variant);
3997 }
3998 }
3999
4000 items.find(|item| name_of(item).eq_ignore_ascii_case(name))
4001}
4002
4003#[cfg(test)]
4004mod tests {
4005 use crate::openrouter::StateRootScope;
4006
4007 #[test]
4008 fn chat_template_missing_only_when_no_template_anywhere() {
4009 let dir = tempfile::tempdir().unwrap();
4010 let cfg = dir.path().join("tokenizer_config.json");
4011 assert!(!chat_template_missing(dir.path()));
4013 std::fs::write(&cfg, r#"{"eos_token":"<|endoftext|>"}"#).unwrap();
4014 assert!(chat_template_missing(dir.path()));
4015 std::fs::write(&cfg, r#"{"chat_template":"{{ messages }}"}"#).unwrap();
4016 assert!(!chat_template_missing(dir.path()));
4017 std::fs::write(&cfg, r#"{"eos_token":"<|endoftext|>"}"#).unwrap();
4018 std::fs::write(dir.path().join("chat_template.jinja"), "{{ messages }}").unwrap();
4019 assert!(!chat_template_missing(dir.path()));
4020 }
4021
4022 #[test]
4030 fn a_sharded_model_missing_one_shard_is_not_installed() {
4031 let tmp = tempfile::tempdir().unwrap();
4032 let dir = tmp.path();
4033 std::fs::write(
4034 dir.join("model.safetensors.index.json"),
4035 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
4036 "b":"model-00002-of-00002.safetensors"}}"#,
4037 )
4038 .unwrap();
4039 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
4041 assert!(
4042 !mlx_dir_has_weights(dir),
4043 "a missing shard must read as not-installed, or pull silently no-ops"
4044 );
4045
4046 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
4048 assert!(
4049 mlx_dir_has_weights(dir),
4050 "a complete shard set must read as installed"
4051 );
4052 }
4053
4054 #[test]
4062 fn missing_shards_are_reported_by_name() {
4063 let tmp = tempfile::tempdir().unwrap();
4064 let dir = tmp.path();
4065 std::fs::write(
4066 dir.join("model.safetensors.index.json"),
4067 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
4068 "b":"model-00002-of-00002.safetensors"}}"#,
4069 )
4070 .unwrap();
4071 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
4072
4073 assert_eq!(
4074 missing_weight_shards(dir),
4075 vec!["model-00001-of-00002.safetensors".to_string()],
4076 "the absent shard must be named, not just counted"
4077 );
4078
4079 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
4080 assert!(
4081 missing_weight_shards(dir).is_empty(),
4082 "a complete shard set must report nothing missing"
4083 );
4084 }
4085
4086 #[test]
4090 fn missing_shards_is_empty_without_an_index() {
4091 let tmp = tempfile::tempdir().unwrap();
4092 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
4093 assert!(missing_weight_shards(tmp.path()).is_empty());
4094
4095 let bad = tempfile::tempdir().unwrap();
4098 std::fs::write(bad.path().join("model.safetensors.index.json"), b"not json").unwrap();
4099 assert!(missing_weight_shards(bad.path()).is_empty());
4100 }
4101
4102 #[test]
4104 fn a_single_file_model_still_counts_without_an_index() {
4105 let tmp = tempfile::tempdir().unwrap();
4106 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
4107 assert!(mlx_dir_has_weights(tmp.path()));
4108 }
4109
4110 #[test]
4113 fn an_unparseable_index_fails_closed() {
4114 let tmp = tempfile::tempdir().unwrap();
4115 std::fs::write(
4116 tmp.path().join("model.safetensors.index.json"),
4117 b"{not-json",
4118 )
4119 .unwrap();
4120 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
4121 assert!(
4122 !mlx_dir_has_weights(tmp.path()),
4123 "an unreadable index must not fall back to a stray weight"
4124 );
4125 }
4126
4127 use super::*;
4128 use tempfile::TempDir;
4129
4130 #[test]
4131 fn mlx_dir_has_weights_detects_completeness() {
4132 let tmp = TempDir::new().unwrap();
4133 let dir = tmp.path();
4134
4135 std::fs::write(dir.join("config.json"), "{}").unwrap();
4137 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
4138 assert!(
4139 !mlx_dir_has_weights(dir),
4140 "config-only stub must not count as installed"
4141 );
4142
4143 std::fs::write(dir.join("model.safetensors.index.json"), "{}").unwrap();
4145 assert!(!mlx_dir_has_weights(dir), "index.json alone is not weights");
4146
4147 std::fs::remove_file(dir.join("model.safetensors.index.json")).unwrap();
4149 std::fs::write(dir.join("model.safetensors"), b"\x00\x01\x02").unwrap();
4150 assert!(mlx_dir_has_weights(dir));
4151 }
4152
4153 #[test]
4154 fn mlx_dir_has_weights_handles_sharded_and_dangling_symlinks() {
4155 let sharded = TempDir::new().unwrap();
4156 std::fs::write(sharded.path().join("config.json"), "{}").unwrap();
4157 std::fs::write(
4158 sharded.path().join("model-00001-of-00002.safetensors"),
4159 b"\x00",
4160 )
4161 .unwrap();
4162 assert!(mlx_dir_has_weights(sharded.path()), "sharded shard counts");
4163
4164 #[cfg(unix)]
4167 {
4168 let dangling = TempDir::new().unwrap();
4169 std::fs::write(dangling.path().join("config.json"), "{}").unwrap();
4170 std::os::unix::fs::symlink(
4171 dangling.path().join("does-not-exist"),
4172 dangling.path().join("model.safetensors"),
4173 )
4174 .unwrap();
4175 assert!(
4176 !mlx_dir_has_weights(dangling.path()),
4177 "dangling weight symlink must count as absent"
4178 );
4179 }
4180 }
4181
4182 struct TestRegistry {
4204 registry: UnifiedRegistry,
4205 _tmp: TempDir,
4209 _environment: tokio::sync::MutexGuard<'static, ()>,
4210 }
4211
4212 impl std::ops::Deref for TestRegistry {
4213 type Target = UnifiedRegistry;
4214
4215 fn deref(&self) -> &Self::Target {
4216 &self.registry
4217 }
4218 }
4219
4220 impl std::ops::DerefMut for TestRegistry {
4221 fn deref_mut(&mut self) -> &mut Self::Target {
4222 &mut self.registry
4223 }
4224 }
4225
4226 fn test_registry() -> TestRegistry {
4227 let _environment = crate::openrouter::test_environment_scope();
4228 let tmp = TempDir::new().unwrap();
4229 let registry = UnifiedRegistry::new_with_state_root(
4230 tmp.path().to_path_buf(),
4231 tmp.path().join("models"),
4232 );
4233 TestRegistry {
4234 registry,
4235 _tmp: tmp,
4236 _environment,
4237 }
4238 }
4239
4240 #[test]
4245 fn a_rows_unknown_credential_state_follows_the_credential_it_needs() {
4246 let row = |source: serde_json::Value, provider: &str| -> ModelSchema {
4247 serde_json::from_value(serde_json::json!({
4248 "id": format!("{provider}/row"), "name": "row", "provider": provider,
4249 "family": "test", "capabilities": ["generate"], "context_length": 1000,
4250 "source": source,
4251 }))
4252 .unwrap()
4253 };
4254 let keyed = row(
4255 serde_json::json!({"type": "remote_api", "endpoint": "https://example.invalid",
4256 "api_key_env": "VENDOR_API_KEY", "api_key_envs": [], "protocol": "anthropic"}),
4257 "vendor",
4258 );
4259 let codex = row(
4260 serde_json::json!({"type": "proprietary", "provider": "openai-codex",
4261 "endpoint": "https://example.invalid", "auth": {"type": "chatgpt_subscription"},
4262 "protocol": {}}),
4263 "openai-codex",
4264 );
4265 let openrouter = row(
4266 serde_json::json!({"type": "remote_api", "endpoint": "https://example.invalid",
4267 "api_key_env": "OPENROUTER_API_KEY", "api_key_envs": [], "protocol": "open_router"}),
4268 "openrouter",
4269 );
4270 let parslee = row(
4271 serde_json::json!({"type": "proprietary", "provider": "parslee",
4272 "endpoint": "https://api.parslee.ai", "auth": {"type": "o_auth2_pkce",
4273 "authority": "https://api.parslee.ai", "client_id": "parslee-car", "scopes": []},
4274 "protocol": {}}),
4275 "parslee",
4276 );
4277
4278 let mut registry = UnifiedRegistry::new_empty(std::path::PathBuf::from("/nonexistent"));
4279 for schema in [&keyed, &codex, &openrouter, &parslee] {
4280 assert!(
4281 !registry.row_credential_state_unknown(schema),
4282 "{}",
4283 schema.id
4284 );
4285 }
4286 registry
4287 .credential_state_unknown
4288 .insert("VENDOR_API_KEY".into());
4289 registry.codex_state_unknown = true;
4290 registry.openrouter_state_unknown = true;
4291 assert!(registry.row_credential_state_unknown(&keyed));
4292 assert!(registry.row_credential_state_unknown(&codex));
4293 assert!(registry.row_credential_state_unknown(&openrouter));
4294 assert!(!registry.row_credential_state_unknown(&parslee));
4295 }
4296
4297 #[test]
4311 fn an_unreadable_credential_is_recorded_as_unknown_not_absent() {
4312 let registry = UnifiedRegistry::new_empty(std::path::PathBuf::from("/nonexistent"));
4316 assert!(
4317 !registry.credential_state_is_unknown("VENDOR_API_KEY"),
4318 "an unprobed registry must not claim a credential state is unknown"
4319 );
4320
4321 assert_eq!(
4325 credential_presence("CAR_TEST_CREDENTIAL_THAT_DOES_NOT_EXIST"),
4326 CredentialPresence::Absent
4327 );
4328 }
4329
4330 #[test]
4331 fn the_withheld_set_covers_keys_and_leaves_settings() {
4332 for name in builtin_credential_env_names() {
4333 assert!(is_credential_env_name(&name), "{name}");
4334 }
4335 for name in EXTRA_CREDENTIAL_ENV_NAMES.iter().chain(&[
4336 "MY_VENDOR_API_KEY",
4337 "SOME_SERVICE_TOKEN",
4338 "DB_PASSWORD",
4339 "X_SECRET",
4340 "CAR_AUTH_TOKEN",
4341 ]) {
4342 assert!(is_credential_env_name(name), "{name}");
4343 }
4344 for name in [
4345 "CAR_NO_AUTH",
4346 "VLLM_MLX_ENDPOINT",
4347 "RUST_LOG",
4348 "CAR_MCP_BIND",
4349 "PARSLEE_SYNC_ORG_SCOPE",
4350 "TOKHN_STT_PROVIDER",
4351 "PARSLEE_API_BASE",
4352 ] {
4353 assert!(!is_credential_env_name(name), "{name}");
4354 }
4355 }
4356
4357 #[tokio::test(flavor = "current_thread")]
4361 async fn a_key_kept_only_in_the_store_reads_as_present_and_one_in_a_hand_made_env_file_does_not(
4362 ) {
4363 let _env = crate::openrouter::test_environment_scope_async().await;
4364 const NAME: &str = "CAR_PRESENCE_TEST_API_KEY";
4365 let home = tempfile::tempdir().unwrap();
4366 let store = home.path().join("store");
4367 std::fs::create_dir_all(&store).unwrap();
4368 std::fs::write(home.path().join("env"), format!("{NAME}=fake-from-file\n")).unwrap();
4369 let previous = (
4370 std::env::var_os(car_home::ENV_VAR),
4371 std::env::var_os("CAR_SECRETS_FILE_DIR"),
4372 std::env::var_os(NAME),
4373 );
4374 std::env::set_var(car_home::ENV_VAR, home.path());
4375 std::env::set_var("CAR_SECRETS_FILE_DIR", &store);
4376 std::env::remove_var(NAME);
4377
4378 let file_only = credential_presence(NAME);
4379 let secrets = car_secrets::ActivityScope::begin();
4380 car_secrets::SecretStore::new()
4381 .put(
4382 &car_secrets::SecretRef::with_default_service(NAME),
4383 "fake-from-store",
4384 )
4385 .unwrap();
4386 let before = secrets.activity();
4387 let store_only = credential_presence(NAME);
4388 let reads = secrets.activity().get_attempts - before.get_attempts;
4389
4390 match previous.0 {
4391 Some(value) => std::env::set_var(car_home::ENV_VAR, value),
4392 None => std::env::remove_var(car_home::ENV_VAR),
4393 }
4394 match previous.1 {
4395 Some(value) => std::env::set_var("CAR_SECRETS_FILE_DIR", value),
4396 None => std::env::remove_var("CAR_SECRETS_FILE_DIR"),
4397 }
4398 if let Some(value) = previous.2 {
4399 std::env::set_var(NAME, value);
4400 }
4401 assert_eq!(file_only, CredentialPresence::Absent);
4402 assert_eq!(store_only, CredentialPresence::Present);
4403 assert_eq!(reads, 0, "presence must not decrypt the value");
4404 }
4405
4406 #[test]
4407 fn credential_required_names_a_key_only_when_a_key_is_the_answer() {
4408 let remote = |available: bool, deprecated: bool| {
4409 let mut m = test_generate_schema(
4410 "vendor/m",
4411 "m",
4412 ModelSource::RemoteApi {
4413 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4414 endpoint: "https://example.invalid/v1".into(),
4415 api_key_env: "VENDOR_API_KEY".into(),
4416 api_key_envs: vec![],
4417 api_version: None,
4418 },
4419 );
4420 m.provider = "vendor".into();
4421 m.available = available;
4422 m.deprecated = deprecated;
4423 m
4424 };
4425
4426 assert_eq!(
4427 remote(false, false).credential_required().as_deref(),
4428 Some("VENDOR_API_KEY"),
4429 "an unavailable plain remote row is exactly the case this field is for"
4430 );
4431
4432 assert_eq!(
4433 remote(true, false).credential_required(),
4434 None,
4435 "an available row needs nothing"
4436 );
4437 assert_eq!(
4438 remote(false, true).credential_required(),
4439 None,
4440 "a deprecated row does not become usable by adding a key"
4441 );
4442
4443 let mut multi = remote(false, false);
4447 if let ModelSource::RemoteApi {
4448 ref mut api_key_envs,
4449 ..
4450 } = multi.source
4451 {
4452 *api_key_envs = vec!["VENDOR_A".into(), "VENDOR_B".into()];
4453 }
4454 assert_eq!(multi.credential_required(), None);
4455
4456 let mut router = remote(false, false);
4459 router.source = ModelSource::RemoteApi {
4460 protocol: crate::schema::ApiProtocol::OpenRouter,
4461 endpoint: "https://openrouter.ai/api/v1".into(),
4462 api_key_env: "OPENROUTER_API_KEY".into(),
4463 api_key_envs: vec![],
4464 api_version: None,
4465 };
4466 assert_eq!(router.credential_required(), None);
4467
4468 let mut local = test_generate_schema(
4470 "mlx/local",
4471 "local",
4472 ModelSource::Mlx {
4473 hf_repo: "org/repo".into(),
4474 hf_weight_file: None,
4475 },
4476 );
4477 local.available = false;
4478 assert_eq!(local.credential_required(), None);
4479 }
4480
4481 fn test_generate_schema(id: &str, name: &str, source: ModelSource) -> ModelSchema {
4482 ModelSchema {
4483 id: id.into(),
4484 name: name.into(),
4485 provider: "local".into(),
4486 family: "qwen3".into(),
4487 version: "test".into(),
4488 capabilities: vec![ModelCapability::Generate],
4489 context_length: 4096,
4490 max_output_tokens: None,
4491 param_count: String::new(),
4492 quantization: None,
4493 performance: PerformanceEnvelope::default(),
4494 cost: CostModel::default(),
4495 source,
4496 tags: vec![],
4497 supported_params: vec![],
4498 public_benchmarks: vec![],
4499 trust_tier: crate::schema::TrustTier::Curated,
4500 deprecated: false,
4501 available: false,
4502 weights_ready: false,
4503 }
4504 }
4505
4506 #[test]
4511 fn model_info_carries_weights_ready_through_the_projection() {
4512 let mut schema = test_generate_schema(
4513 "mlx-community/car894-test-4bit",
4514 "car894-test-4bit",
4515 ModelSource::Mlx {
4516 hf_repo: "mlx-community/car894-test-4bit".into(),
4517 hf_weight_file: None,
4518 },
4519 );
4520
4521 schema.weights_ready = false;
4522 assert!(
4523 !ModelInfo::from(&schema).weights_ready,
4524 "a schema with no weights on disk must project weights_ready = false"
4525 );
4526
4527 schema.weights_ready = true;
4528 assert!(
4529 ModelInfo::from(&schema).weights_ready,
4530 "a schema with weights on disk must project weights_ready = true"
4531 );
4532 }
4533
4534 #[test]
4540 fn model_info_carries_downloads_weights_through_the_projection() {
4541 let mlx = test_generate_schema(
4542 "mlx-community/car894-test-4bit",
4543 "car894-test-4bit",
4544 ModelSource::Mlx {
4545 hf_repo: "mlx-community/car894-test-4bit".into(),
4546 hf_weight_file: None,
4547 },
4548 );
4549 assert!(
4550 ModelInfo::from(&mlx).downloads_weights,
4551 "an MLX entry downloads weights"
4552 );
4553
4554 for (label, source) in [
4557 ("windows speech", ModelSource::WindowsSpeech {}),
4558 (
4559 "apple foundation",
4560 ModelSource::AppleFoundationModels { use_case: None },
4561 ),
4562 ] {
4563 let schema = test_generate_schema("car894/os-model", "os-model", source);
4564 let info = ModelInfo::from(&schema);
4565 assert!(
4566 !info.downloads_weights,
4567 "{label} installs nothing, so the projection must say so"
4568 );
4569 assert!(
4570 info.is_local,
4571 "{label} is still local — which is exactly why is_local cannot stand in"
4572 );
4573 }
4574
4575 let external = test_generate_schema(
4580 "car894/external-model",
4581 "external-model",
4582 ModelSource::VllmMlx {
4583 endpoint: "http://localhost:8000".into(),
4584 model_name: "mlx-community/car894-test-4bit".into(),
4585 },
4586 );
4587 assert!(!external.is_local());
4588 assert!(external.is_remote());
4589 assert!(!external.requires_apple_silicon());
4590 let info = ModelInfo::from(&external);
4591 assert!(!info.is_local);
4592 assert!(
4593 !info.downloads_weights,
4594 "external vllm-mlx owns its weights, so CAR installs nothing"
4595 );
4596 }
4597
4598 #[test]
4599 fn model_info_classifies_only_raw_vllm_mlx_as_operator_managed_external() {
4600 for endpoint in ["http://localhost:8000", "https://models.example.invalid/v1"] {
4601 let schema = test_generate_schema(
4602 "external/model",
4603 "external-model",
4604 ModelSource::VllmMlx {
4605 endpoint: endpoint.into(),
4606 model_name: "mlx-community/external-model".into(),
4607 },
4608 );
4609 let info = ModelInfo::from(&schema);
4610 assert!(info.operator_managed_external_runtime);
4611 assert_eq!(
4612 serde_json::to_value(info).unwrap()["operator_managed_external_runtime"],
4613 true
4614 );
4615 }
4616
4617 for source in [
4618 ModelSource::RemoteApi {
4619 endpoint: "https://cloud.example.invalid/v1".into(),
4620 api_key_env: "CAR_TEST_KEY".into(),
4621 api_key_envs: vec![],
4622 api_version: None,
4623 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4624 },
4625 ModelSource::ManagedVllmMlx {
4626 hf_repo: "mlx-community/car-owned-model".into(),
4627 hf_weight_file: None,
4628 draft_hf_repo: None,
4629 },
4630 ] {
4631 assert!(
4632 !ModelInfo::from(&test_generate_schema(
4633 "not-external/model",
4634 "not-external-model",
4635 source,
4636 ))
4637 .operator_managed_external_runtime
4638 );
4639 }
4640 }
4641
4642 #[test]
4649 fn fresh_machine_mlx_entry_is_available_but_not_weights_ready() {
4650 let mut reg = test_registry();
4651 let id = "mlx-community/car894-fresh-4bit";
4652 reg.register(test_generate_schema(
4653 id,
4654 "car894-fresh-4bit",
4655 ModelSource::Mlx {
4656 hf_repo: "mlx-community/car894-fresh-4bit".into(),
4657 hf_weight_file: None,
4658 },
4659 ));
4660
4661 let registered = reg
4662 .get(id)
4663 .expect("the model just registered must be in the registry");
4664 let info = ModelInfo::from(registered);
4665
4666 assert!(
4668 !registered.weights_ready,
4669 "an empty models dir means no weights on disk"
4670 );
4671 assert!(
4672 !info.weights_ready,
4673 "the CLI-facing projection must report the same: nothing installed"
4674 );
4675
4676 #[cfg(car_mlxlm_swift_built)]
4677 {
4678 assert!(
4681 registered.available,
4682 "a declared hf_repo makes an MLX entry runnable before download (#164)"
4683 );
4684 assert!(
4685 info.available,
4686 "the projection must keep reporting it as runnable"
4687 );
4688 }
4689 #[cfg(not(car_mlxlm_swift_built))]
4690 {
4691 assert!(
4694 !registered.available,
4695 "the Swift MLX backend is absent, so it must not be runnable"
4696 );
4697 assert!(!info.available);
4698 }
4699 }
4700
4701 fn write_complete_mlx_snapshot(dir: &Path) {
4702 std::fs::create_dir_all(dir).unwrap();
4703 std::fs::write(dir.join("config.json"), b"{}").unwrap();
4704 std::fs::write(dir.join("tokenizer.json"), b"{}").unwrap();
4705 std::fs::write(
4706 dir.join("model.safetensors.index.json"),
4707 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
4708 )
4709 .unwrap();
4710 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"one").unwrap();
4711 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
4712 }
4713
4714 #[test]
4715 fn complete_managed_and_shared_mlx_snapshots_are_physically_ready() {
4716 let schema = test_generate_schema(
4717 "mlx/qwen3-4b:4bit",
4718 "Qwen3-4B-MLX",
4719 ModelSource::Mlx {
4720 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
4721 hf_weight_file: None,
4722 },
4723 );
4724 let root = tempfile::tempdir().unwrap();
4725 let managed = root.path().join("managed");
4726 let shared = root.path().join("shared");
4727
4728 write_complete_mlx_snapshot(&managed);
4729 assert!(mlx_weights_ready_at(&schema, &managed, None));
4730
4731 std::fs::remove_dir_all(&managed).unwrap();
4732 write_complete_mlx_snapshot(&shared);
4733 assert!(mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4734 }
4735
4736 #[test]
4737 fn zero_byte_gguf_is_not_physically_ready() {
4738 let schema = test_generate_schema(
4739 "qwen/qwen3-4b:q4_k_m",
4740 "Qwen3-4B",
4741 ModelSource::Local {
4742 hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
4743 hf_filename: "model.gguf".into(),
4744 tokenizer_repo: "Qwen/Qwen3-4B".into(),
4745 },
4746 );
4747 let root = tempfile::tempdir().unwrap();
4748 let model_dir = root.path().join(&schema.name);
4749 std::fs::create_dir_all(&model_dir).unwrap();
4750 std::fs::write(model_dir.join("model.gguf"), b"").unwrap();
4751
4752 assert!(!physical_weights_ready(&schema, root.path()));
4753 std::fs::write(model_dir.join("model.gguf"), b"gguf").unwrap();
4754 assert!(physical_weights_ready(&schema, root.path()));
4755 }
4756
4757 #[test]
4758 fn shared_mlx_snapshot_missing_an_indexed_shard_is_not_physically_ready() {
4759 let schema = test_generate_schema(
4760 "mlx/qwen3-8b:4bit",
4761 "Qwen3-8B-MLX",
4762 ModelSource::Mlx {
4763 hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
4764 hf_weight_file: None,
4765 },
4766 );
4767 let root = tempfile::tempdir().unwrap();
4768 let managed = root.path().join("managed");
4769 let shared = root.path().join("shared");
4770 write_complete_mlx_snapshot(&shared);
4771 std::fs::remove_file(shared.join("model-00001-of-00002.safetensors")).unwrap();
4772
4773 assert!(!mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4774 }
4775
4776 #[test]
4808 fn a_gateway_that_reports_no_upstream_stops_being_advertised() {
4809 let _guard = crate::openrouter::test_environment_scope();
4810 let _home = StateRootScope::new();
4813 crate::openrouter::clear_gateway_unconfigured();
4814
4815 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
4816 .into_iter()
4817 .filter(|s| crate::openrouter::is_curated_managed_gateway_alias(&s.id))
4818 .collect();
4819 assert!(
4820 !managed.is_empty(),
4821 "precondition: the curated catalog must still carry managed aliases"
4822 );
4823
4824 let availability_of = |schema: &ModelSchema| match &schema.source {
4825 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
4826 &schema.id,
4827 &schema.provider,
4828 provider,
4829 auth,
4830 true,
4832 &std::collections::HashMap::new(),
4833 ),
4834 other => panic!("managed aliases must be Proprietary, got {other:?}"),
4835 };
4836
4837 assert!(
4838 managed.iter().all(availability_of),
4839 "precondition: an authenticated session advertises these today"
4840 );
4841
4842 crate::openrouter::note_gateway_unconfigured();
4843 assert!(
4844 managed.iter().all(|s| !availability_of(s)),
4845 "after the gateway says it has no OpenRouter upstream, every alias in \
4846 the namespace must report unavailable — that claim is what cost the \
4847 benchmark sweep in #786"
4848 );
4849
4850 crate::openrouter::clear_gateway_unconfigured();
4854 assert!(
4855 managed.iter().all(availability_of),
4856 "the suppression must be recoverable, not a one-way latch"
4857 );
4858 }
4859
4860 #[test]
4874 fn constructing_a_registry_with_a_live_session_leaves_the_gateway_observation_alone() {
4875 let _guard = crate::openrouter::test_environment_scope();
4876 let home = StateRootScope::new();
4877
4878 crate::openrouter::note_gateway_unconfigured();
4879 let _registry = UnifiedRegistry::new_with_session(
4880 home.path().to_path_buf(),
4881 home.path().join("models"),
4882 None,
4883 SessionProbe::Fixed(true),
4884 );
4885
4886 let observed = crate::openrouter::gateway_unconfigured();
4887 let persisted = crate::openrouter::gateway_state_path().exists();
4888
4889 crate::openrouter::clear_gateway_unconfigured();
4890
4891 assert!(
4892 observed,
4893 "a signed-in session has no reason to forget what the gateway said"
4894 );
4895 assert!(
4896 persisted,
4897 "the durable half of the observation must survive construction too"
4898 );
4899 }
4900
4901 #[test]
4909 fn constructing_a_registry_with_no_session_still_forgets_the_gateway_observation() {
4910 let _guard = crate::openrouter::test_environment_scope();
4911 let home = StateRootScope::new();
4912
4913 crate::openrouter::note_gateway_unconfigured();
4914 let recorded = crate::openrouter::gateway_unconfigured();
4915 let _registry = UnifiedRegistry::new_with_session(
4916 home.path().to_path_buf(),
4917 home.path().join("models"),
4918 None,
4919 SessionProbe::Fixed(false),
4920 );
4921
4922 let observed = crate::openrouter::gateway_unconfigured();
4923 let persisted = crate::openrouter::gateway_state_path().exists();
4924
4925 crate::openrouter::clear_gateway_unconfigured();
4926
4927 assert!(
4928 recorded,
4929 "precondition: the observation is on record before construction"
4930 );
4931 assert!(
4932 !observed,
4933 "sign-out must still discard the session-scoped verdict (#786)"
4934 );
4935 assert!(
4936 !persisted,
4937 "and the durable copy with it — otherwise the next sign-in inherits it from disk"
4938 );
4939 }
4940
4941 #[test]
4958 fn an_ordinary_test_registry_does_not_disturb_a_separately_set_observation() {
4959 let _guard = crate::openrouter::test_environment_scope();
4960 let home = StateRootScope::new();
4961
4962 crate::openrouter::note_gateway_unconfigured();
4963 let _registry = UnifiedRegistry::new_with_state_root(
4964 home.path().to_path_buf(),
4965 home.path().join("models"),
4966 );
4967
4968 let observed = crate::openrouter::gateway_unconfigured();
4969
4970 crate::openrouter::clear_gateway_unconfigured();
4971
4972 assert!(
4973 observed,
4974 "constructing a registry is not a statement about the session, so it \
4975 must not erase an observation another test just recorded (#986)"
4976 );
4977 assert!(
4978 !SessionProbe::Inert.may_forget_session_evidence(),
4979 "the `cfg(test)` construction default must be a probe that answers \
4980 the session question without acting on it — this is the half of \
4981 the guarantee that does not depend on whether the runner happens \
4982 to be signed in"
4983 );
4984 }
4985
4986 #[test]
4999 fn a_rejected_credential_stops_the_managed_lane_being_advertised() {
5000 let _guard = crate::openrouter::test_environment_scope();
5001 let _home = StateRootScope::new();
5002 crate::parslee_credential::clear_credential_rejected();
5003
5004 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
5005 .into_iter()
5006 .filter(|s| s.provider == "parslee")
5007 .collect();
5008 assert!(
5009 !managed.is_empty(),
5010 "precondition: the curated catalog must still carry parslee rows"
5011 );
5012
5013 let availability_of = |schema: &ModelSchema| match &schema.source {
5014 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
5015 &schema.id,
5016 &schema.provider,
5017 provider,
5018 auth,
5019 true,
5021 &std::collections::HashMap::new(),
5022 ),
5023 other => panic!("parslee rows must be Proprietary, got {other:?}"),
5024 };
5025
5026 assert!(
5027 managed.iter().all(availability_of),
5028 "precondition: an authenticated session advertises these today"
5029 );
5030
5031 crate::parslee_credential::note_credential_rejected();
5032 assert!(
5033 managed.iter().all(|s| !availability_of(s)),
5034 "after the server rejects the credential, EVERY parslee row must \
5035 report unavailable — unlike the gateway verdict this is not scoped \
5036 to the curated OpenRouter aliases, because a dead credential kills \
5037 the whole namespace"
5038 );
5039
5040 crate::parslee_credential::clear_credential_rejected();
5043 assert!(
5044 managed.iter().all(availability_of),
5045 "the suppression must lift once the credential works again"
5046 );
5047 }
5048
5049 #[test]
5066 fn an_explicit_status_refresh_sees_the_same_environment_credential() {
5067 let _environment = crate::openrouter::test_environment_scope();
5068 let tmp = TempDir::new().unwrap();
5069 let env_var = "CAR_TEST_EXPLICIT_STATUS_KEY";
5070 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
5071 let mut schema = test_generate_schema(
5072 "vendor/explicit-status",
5073 "explicit-status",
5074 ModelSource::RemoteApi {
5075 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5076 endpoint: "https://example.invalid/v1".into(),
5077 api_key_env: env_var.into(),
5078 api_key_envs: vec![],
5079 api_version: None,
5080 },
5081 );
5082 schema.provider = "vendor".into();
5083 registry.register(schema);
5084
5085 std::env::remove_var(env_var);
5086 registry.refresh_availability();
5087 assert!(
5088 !registry.get("vendor/explicit-status").unwrap().available,
5089 "no credential anywhere: the passive refresh must report unavailable"
5090 );
5091 registry.refresh_availability_for_explicit_status();
5092 assert!(
5093 !registry.get("vendor/explicit-status").unwrap().available,
5094 "no credential anywhere: the explicit refresh must agree"
5095 );
5096
5097 std::env::set_var(env_var, "sk-present");
5098 registry.refresh_availability();
5099 assert!(
5100 registry.get("vendor/explicit-status").unwrap().available,
5101 "exported credential: the passive refresh must see it"
5102 );
5103 registry.refresh_availability_for_explicit_status();
5104 assert!(
5105 registry.get("vendor/explicit-status").unwrap().available,
5106 "exported credential: the explicit refresh must see it too — if this fails \
5107 the authoritative mode is resolving through a path that cannot see the \
5108 environment, and `car models list` is answering from something else again"
5109 );
5110 std::env::remove_var(env_var);
5111 }
5112
5113 #[test]
5114 fn refresh_availability_probes_each_credential_once_not_per_model() {
5115 let _environment = crate::openrouter::test_environment_scope();
5128 let tmp = TempDir::new().unwrap();
5129 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
5130 for i in 0..25 {
5131 let mut schema = test_generate_schema(
5132 &format!("openrouter/model-{i}"),
5133 &format!("model-{i}"),
5134 ModelSource::RemoteApi {
5135 protocol: crate::schema::ApiProtocol::OpenRouter,
5136 endpoint: "https://openrouter.ai/api/v1".into(),
5137 api_key_env: "OPENROUTER_API_KEY".into(),
5138 api_key_envs: vec![],
5139 api_version: None,
5140 },
5141 );
5142 schema.provider = "openrouter".into();
5143 registry.register(schema);
5144 }
5145
5146 crate::openrouter::reset_credential_source_call_count();
5147 registry.refresh_availability();
5148 let calls = crate::openrouter::credential_source_call_count();
5149
5150 assert_eq!(
5151 calls, 1,
5152 "refresh_availability probed the OpenRouter credential {calls} times for 25 models; \
5153 it must resolve each distinct credential once per refresh, not once per model"
5154 );
5155 }
5156
5157 #[test]
5163 fn a_vllm_mlx_pull_targets_the_shared_huggingface_cache() {
5164 let repo = "mlx-community/Qwen3.8-27B-4bit";
5165 let dir = crate::hf_cache::repo_dir(repo);
5166 assert!(
5167 dir.ends_with("models--mlx-community--Qwen3.8-27B-4bit"),
5168 "got {}",
5169 dir.display()
5170 );
5171 assert!(
5172 dir.parent().is_some_and(|p| p.ends_with("hub")),
5173 "must live under the HF cache's hub/ root, got {}",
5174 dir.display()
5175 );
5176 }
5177
5178 #[test]
5181 fn external_vllm_mlx_does_not_become_managed_from_a_loopback_endpoint() {
5182 let _environment = crate::openrouter::test_environment_scope();
5195 let tmp = TempDir::new().unwrap();
5196 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
5197 let schema = test_generate_schema(
5198 "vllm-mlx/arch-the-rust-backend-cannot-load",
5199 "external-only-model",
5200 ModelSource::VllmMlx {
5201 endpoint: "http://localhost:8000".into(),
5202 model_name: "mlx-community/Qwen3.8-27B-4bit".into(),
5203 },
5204 );
5205 registry.register(schema);
5206
5207 assert!(
5209 std::env::var("VLLM_MLX_ENDPOINT").is_err(),
5210 "test precondition: VLLM_MLX_ENDPOINT must be unset"
5211 );
5212 registry.refresh_availability();
5213
5214 let model = registry
5215 .get("vllm-mlx/arch-the-rust-backend-cannot-load")
5216 .expect("registered model should be present");
5217 assert!(
5218 !model.available,
5219 "an external vllm-mlx row remains external even on loopback; only an \
5220 explicit ManagedVllmMlx source may use CAR's runtime"
5221 );
5222 }
5223
5224 #[test]
5225 fn user_config_load_and_save_force_community_trust() {
5226 let tmp = TempDir::new().unwrap();
5227 let models_dir = tmp.path().join("models");
5228 let config_path = tmp.path().join("models.json");
5229 let schema = test_generate_schema(
5230 "user/test-model",
5231 "user-test-model",
5232 ModelSource::RemoteApi {
5233 endpoint: "https://attacker.invalid/v1/chat/completions".into(),
5234 api_key_env: "CAR_USER_MODEL_TEST_KEY".into(),
5235 api_key_envs: vec![],
5236 api_version: None,
5237 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5238 },
5239 );
5240 let mut omitted_tier = serde_json::to_value(schema.clone()).unwrap();
5241 omitted_tier.as_object_mut().unwrap().remove("trust_tier");
5242 std::fs::write(
5243 &config_path,
5244 serde_json::to_vec_pretty(&vec![omitted_tier]).unwrap(),
5245 )
5246 .unwrap();
5247
5248 let mut loaded = UnifiedRegistry::new_empty(models_dir.clone());
5249 loaded.load_user_config().unwrap();
5250 assert_eq!(
5251 loaded.get("user/test-model").unwrap().trust_tier,
5252 crate::schema::TrustTier::Community
5253 );
5254
5255 let mut persisted = UnifiedRegistry::new_empty(models_dir);
5256 persisted.register_user_model(schema);
5257 persisted.save_user_config().unwrap();
5258 let saved: Vec<ModelSchema> =
5259 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5260 assert_eq!(saved.len(), 1);
5261 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
5262 }
5263
5264 #[test]
5265 fn persisted_user_model_cannot_shadow_managed_openrouter_alias() {
5266 let _environment = crate::openrouter::test_environment_scope();
5271 let tmp = TempDir::new().unwrap();
5272 let models_dir = tmp.path().join("models");
5273 let config_path = tmp.path().join("models.json");
5274 let mut shadow = crate::openrouter::curated_schemas()
5275 .into_iter()
5276 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
5277 .unwrap();
5278 shadow.provider = "attacker".into();
5279 std::fs::write(
5280 &config_path,
5281 serde_json::to_vec_pretty(&vec![shadow]).unwrap(),
5282 )
5283 .unwrap();
5284
5285 let registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
5286 let actual = registry
5287 .get("parslee/openrouter/frontier-general")
5288 .expect("compiled managed alias must remain present");
5289 assert_eq!(actual.provider, "parslee");
5290 assert_eq!(
5291 crate::openrouter::canonical_managed_gateway_selector(actual),
5292 Some("parslee/openrouter/frontier-general")
5293 );
5294 }
5295
5296 fn signed_remote(id: &str, name: &str) -> ModelSchema {
5297 test_generate_schema(
5298 id,
5299 name,
5300 ModelSource::RemoteApi {
5301 endpoint: "https://catalog.example/v1".into(),
5302 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5303 api_key_envs: vec![],
5304 api_version: None,
5305 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5306 },
5307 )
5308 }
5309
5310 #[test]
5311 fn a_user_registered_row_wins_a_live_signed_swap_and_stays_in_models_json() {
5312 let _environment = crate::openrouter::test_environment_scope();
5313 let tmp = TempDir::new().unwrap();
5314 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5315 tmp.path().to_path_buf(),
5316 tmp.path().join("models"),
5317 None,
5318 );
5319 let mut mine = signed_remote("shared/id", "mine");
5320 mine.context_length = 1234;
5321 registry.register_user_model(mine);
5322 let mut publishers = signed_remote("shared/id", "publishers");
5323 publishers.context_length = 9999;
5324 assert_eq!(
5325 registry.replace_signed_catalog(
5326 vec![publishers],
5327 None,
5328 &HashSet::new(),
5329 &HashSet::new()
5330 ),
5331 0
5332 );
5333 assert_eq!(registry.get("shared/id").unwrap().context_length, 1234);
5334 registry.save_user_config().unwrap();
5335 let saved = std::fs::read_to_string(tmp.path().join(USER_MODELS_FILE)).unwrap();
5336 assert!(saved.contains("1234") && !saved.contains("9999"), "{saved}");
5337 assert!(registry.unregister_user_model("shared/id").is_some());
5338 }
5339
5340 #[test]
5341 fn a_withdrawn_row_something_depends_on_stays_deprecated_until_nothing_does() {
5342 let _environment = crate::openrouter::test_environment_scope();
5343 let tmp = TempDir::new().unwrap();
5344 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5345 tmp.path().to_path_buf(),
5346 tmp.path().join("models"),
5347 None,
5348 );
5349 let used = signed_remote("signed/used", "used");
5350 let idle = signed_remote("signed/idle", "idle");
5351 assert_eq!(
5352 registry.replace_signed_catalog(
5353 vec![used, idle],
5354 None,
5355 &HashSet::new(),
5356 &HashSet::new()
5357 ),
5358 2
5359 );
5360 let keep: HashSet<String> = ["signed/used".to_string()].into();
5361 assert_eq!(
5362 registry.replace_signed_catalog(vec![], None, &keep, &HashSet::new()),
5363 0
5364 );
5365 assert!(
5366 registry.get("signed/idle").is_none(),
5367 "nothing depended on it"
5368 );
5369 let kept = registry.get("signed/used").expect("a dependent keeps it");
5370 assert!(kept.deprecated, "kept, but never suggested");
5371 let used = signed_remote("signed/used", "used");
5373 assert_eq!(
5374 registry.replace_signed_catalog(vec![used], None, &keep, &HashSet::new()),
5375 1
5376 );
5377 assert!(!registry.get("signed/used").unwrap().deprecated);
5378 registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5380 assert!(registry.get("signed/used").is_none());
5381 }
5382
5383 #[test]
5384 fn a_kept_row_survives_a_restart_only_while_its_signed_catalog_verifies() {
5385 let _environment = crate::openrouter::test_environment_scope();
5386 let tmp = TempDir::new().unwrap();
5387 let open = |key: Option<&str>| {
5388 UnifiedRegistry::new_with_catalog_public_key(
5389 tmp.path().to_path_buf(),
5390 tmp.path().join("models"),
5391 key,
5392 )
5393 };
5394 let cache = crate::catalog::cache_path(tmp.path());
5395 let retained = crate::catalog::retained_path(tmp.path());
5396 let catalog = |models, version| {
5397 crate::catalog::signed_test_catalog(
5398 crate::catalog::CatalogDoc {
5399 revoked: Vec::new(),
5400 version,
5401 models,
5402 },
5403 21,
5404 )
5405 };
5406 let (v1, key) = catalog(
5407 vec![
5408 signed_remote("signed/used", "used"),
5409 signed_remote("signed/idle", "idle"),
5410 ],
5411 1,
5412 );
5413 crate::catalog::save_verified(&cache, &v1).unwrap();
5414 let mut running = open(Some(&key));
5415 assert!(running.get("signed/used").is_some());
5416
5417 let (v2, _) = catalog(vec![], 2);
5419 let keep: HashSet<String> = ["signed/used".to_string()].into();
5420 running.replace_signed_catalog(
5421 v2.clone().into_models(),
5422 Some(std::sync::Arc::new(v2.envelope())),
5423 &keep,
5424 &HashSet::new(),
5425 );
5426 crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
5427 crate::catalog::save_verified(&cache, &v2).unwrap();
5428
5429 let restarted = open(Some(&key));
5430 let kept = restarted.get("signed/used").expect("kept across a restart");
5431 assert!(kept.deprecated);
5432 assert!(restarted.get("signed/idle").is_none());
5433 let (_, other_key) = crate::catalog::signed_test_catalog(
5435 crate::catalog::CatalogDoc {
5436 revoked: Vec::new(),
5437 version: 9,
5438 models: vec![],
5439 },
5440 22,
5441 );
5442 assert!(open(Some(&other_key)).get("signed/used").is_none());
5443 let json = std::fs::read_to_string(&retained).unwrap();
5445 std::fs::write(&retained, json.replace("signed/idle", "signed/idlx")).unwrap();
5446 assert!(open(Some(&key)).get("signed/used").is_none());
5447 }
5448
5449 #[test]
5450 fn a_revoked_row_leaves_even_when_something_depends_on_it() {
5451 let _environment = crate::openrouter::test_environment_scope();
5452 let tmp = TempDir::new().unwrap();
5453 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5454 tmp.path().to_path_buf(),
5455 tmp.path().join("models"),
5456 None,
5457 );
5458 let none = HashSet::new();
5459 let bad: HashSet<String> = ["signed/bad".to_string()].into();
5460 registry.replace_signed_catalog(
5461 vec![signed_remote("signed/bad", "bad")],
5462 None,
5463 &none,
5464 &none,
5465 );
5466 registry.replace_signed_catalog(vec![], None, &bad, &none);
5468 assert!(registry.get("signed/bad").is_some());
5469 registry.replace_signed_catalog(vec![], None, &bad, &bad);
5471 assert!(registry.get("signed/bad").is_none());
5472 registry.replace_signed_catalog(
5474 vec![signed_remote("signed/bad", "bad")],
5475 None,
5476 &none,
5477 &bad,
5478 );
5479 assert!(registry.get("signed/bad").is_none());
5480 assert!(registry.retained_rows().is_empty());
5481
5482 let builtin = builtin_catalog()
5484 .into_iter()
5485 .find(|m| !m.deprecated)
5486 .expect("a current builtin");
5487 let revoked: HashSet<String> = [builtin.id.clone()].into();
5488 registry.replace_signed_catalog(vec![], None, &none, &revoked);
5489 assert!(registry.get(&builtin.id).unwrap().deprecated);
5490 registry.replace_signed_catalog(vec![], None, &none, &none);
5491 assert!(!registry.get(&builtin.id).unwrap().deprecated);
5492 }
5493
5494 #[test]
5495 fn a_restart_never_restores_a_row_the_current_catalog_revokes() {
5496 let _environment = crate::openrouter::test_environment_scope();
5497 let tmp = TempDir::new().unwrap();
5498 let catalog = |models, revoked, version| {
5499 crate::catalog::signed_test_catalog(
5500 crate::catalog::CatalogDoc {
5501 version,
5502 models,
5503 revoked,
5504 },
5505 23,
5506 )
5507 };
5508 let (v1, key) = catalog(vec![signed_remote("signed/kept", "kept")], vec![], 1);
5509 let mut running = UnifiedRegistry::new_with_catalog_public_key(
5510 tmp.path().to_path_buf(),
5511 tmp.path().join("models"),
5512 Some(&key),
5513 );
5514 running.replace_signed_catalog(
5515 v1.clone().into_models(),
5516 Some(std::sync::Arc::new(v1.envelope())),
5517 &HashSet::new(),
5518 &HashSet::new(),
5519 );
5520 let (v2, _) = catalog(vec![], vec![], 2);
5522 let keep: HashSet<String> = ["signed/kept".to_string()].into();
5523 running.replace_signed_catalog(
5524 vec![],
5525 Some(std::sync::Arc::new(v2.envelope())),
5526 &keep,
5527 &HashSet::new(),
5528 );
5529 let retained = crate::catalog::retained_path(tmp.path());
5530 crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
5531 let (v3, _) = catalog(vec![], vec!["signed/kept".to_string()], 3);
5533 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &v3).unwrap();
5534 let restarted = UnifiedRegistry::new_with_catalog_public_key(
5535 tmp.path().to_path_buf(),
5536 tmp.path().join("models"),
5537 Some(&key),
5538 );
5539 assert!(restarted.get("signed/kept").is_none());
5540 }
5541
5542 #[test]
5543 fn a_revoked_id_never_comes_back_from_the_discovery_cache() {
5544 let _environment = crate::openrouter::test_environment_scope();
5545 let tmp = TempDir::new().unwrap();
5546 let (catalog, key) = crate::catalog::signed_test_catalog(
5547 crate::catalog::CatalogDoc {
5548 version: 1,
5549 models: vec![],
5550 revoked: vec!["discovered/bad".to_string()],
5551 },
5552 24,
5553 );
5554 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &catalog).unwrap();
5555 crate::discovery::save_cache(
5558 &crate::discovery::cache_path(&tmp.path().join("models")),
5559 &[
5560 signed_remote("discovered/bad", "bad"),
5561 signed_remote("discovered/fine", "fine"),
5562 ],
5563 )
5564 .unwrap();
5565 let restarted = UnifiedRegistry::new_with_catalog_public_key(
5566 tmp.path().to_path_buf(),
5567 tmp.path().join("models"),
5568 Some(&key),
5569 );
5570 assert!(restarted.get("discovered/bad").is_none());
5571 assert!(restarted.get("discovered/fine").is_some());
5572
5573 let mut running = UnifiedRegistry::new_with_catalog_public_key(
5576 tmp.path().to_path_buf(),
5577 tmp.path().join("models"),
5578 None,
5579 );
5580 assert!(running.get("discovered/bad").is_some());
5581 let revoked: HashSet<String> = ["discovered/bad".to_string()].into();
5582 running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
5583 assert!(running.get("discovered/bad").is_none());
5584 assert!(running.get("discovered/fine").is_some());
5585
5586 running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5588 assert!(running.get("discovered/bad").is_some());
5589 assert!(!running.is_revoked("discovered/bad"));
5590
5591 running.register_user_model(signed_remote("discovered/bad", "mine"));
5594 running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
5595 assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5596 running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5597 assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5598 }
5599
5600 #[test]
5601 fn a_signed_overlay_updates_a_builtins_measurements_never_its_weights() {
5602 let _environment = crate::openrouter::test_environment_scope();
5603 let tmp = TempDir::new().unwrap();
5604 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5605 tmp.path().to_path_buf(),
5606 tmp.path().join("models"),
5607 None,
5608 );
5609 let compiled = builtin_catalog()
5610 .into_iter()
5611 .find(|m| m.public_benchmarks.iter().any(|b| b.name == "car-judged"))
5612 .expect("a scored builtin");
5613 let mut remeasured = compiled.clone();
5614 remeasured.public_benchmarks[0].score = 0.42;
5615 remeasured.public_benchmarks[0].runs = Some(3);
5616 remeasured.context_length = compiled.context_length + 1;
5617 remeasured.name = "renamed by the publisher".into();
5618 assert_eq!(
5619 registry.replace_signed_catalog(
5620 vec![remeasured.clone()],
5621 None,
5622 &HashSet::new(),
5623 &HashSet::new()
5624 ),
5625 1
5626 );
5627 let row = registry.get(&compiled.id).unwrap();
5628 assert_eq!(row.public_benchmarks[0].score, 0.42);
5629 assert_eq!(row.public_benchmarks[0].runs, Some(3));
5630 assert_eq!(
5631 row.context_length, compiled.context_length,
5632 "not an overlay field"
5633 );
5634 assert_eq!(row.name, compiled.name, "not an overlay field");
5635 registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5637 assert_eq!(
5638 registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5639 compiled.public_benchmarks[0].score
5640 );
5641 let mut repointed = remeasured;
5643 repointed.source = signed_remote("x/y", "y").source;
5644 assert_eq!(
5645 registry.replace_signed_catalog(
5646 vec![repointed],
5647 None,
5648 &HashSet::new(),
5649 &HashSet::new()
5650 ),
5651 0
5652 );
5653 assert_eq!(
5654 registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5655 compiled.public_benchmarks[0].score
5656 );
5657 }
5658
5659 #[test]
5660 fn user_config_persistence_excludes_signed_rows_and_keeps_builtin_tagged_user_rows() {
5661 let _environment = crate::openrouter::test_environment_scope();
5666 let tmp = TempDir::new().unwrap();
5667 let models_dir = tmp.path().join("models");
5668 let config_path = tmp.path().join("models.json");
5669
5670 let signed = test_generate_schema(
5671 "signed/catalog-only",
5672 "signed-catalog-only",
5673 ModelSource::RemoteApi {
5674 endpoint: "https://catalog.example/v1".into(),
5675 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5676 api_key_envs: vec![],
5677 api_version: None,
5678 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5679 },
5680 );
5681 assert!(!signed.tags.iter().any(|tag| tag == "builtin"));
5682 let (verified, public_key) = crate::catalog::signed_test_catalog(
5683 crate::catalog::CatalogDoc {
5684 revoked: Vec::new(),
5685 version: 81,
5686 models: vec![signed],
5687 },
5688 81,
5689 );
5690 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
5691
5692 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5693 tmp.path().to_path_buf(),
5694 models_dir.clone(),
5695 Some(public_key.as_str()),
5696 );
5697 let mut user = test_generate_schema(
5698 "user/builtin-tagged",
5699 "user-builtin-tagged",
5700 ModelSource::RemoteApi {
5701 endpoint: "https://user.example/v1".into(),
5702 api_key_env: "USER_MODEL_TEST_KEY".into(),
5703 api_key_envs: vec![],
5704 api_version: None,
5705 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5706 },
5707 );
5708 user.tags.push("builtin".into());
5709 registry.register_user_model(user);
5710 registry.save_user_config().unwrap();
5711
5712 let saved: Vec<ModelSchema> =
5713 serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
5714 assert_eq!(
5715 saved
5716 .iter()
5717 .map(|model| model.id.as_str())
5718 .collect::<Vec<_>>(),
5719 vec!["user/builtin-tagged"],
5720 "models.json must contain only explicitly user-registered rows"
5721 );
5722 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
5723
5724 let mut restarted = UnifiedRegistry::new_with_catalog_public_key(
5725 tmp.path().to_path_buf(),
5726 models_dir,
5727 Some(public_key.as_str()),
5728 );
5729 assert_eq!(
5730 restarted.get("signed/catalog-only").unwrap().trust_tier,
5731 crate::schema::TrustTier::Curated,
5732 "user persistence must not demote an unrelated signed catalog row"
5733 );
5734 assert_eq!(
5735 restarted.get("user/builtin-tagged").unwrap().trust_tier,
5736 crate::schema::TrustTier::Community
5737 );
5738 let mut signed_shadow = restarted.get("signed/catalog-only").unwrap().clone();
5739 signed_shadow.name = "user-shadow-of-signed-row".into();
5740 restarted.register_user_model(signed_shadow);
5741 assert_eq!(
5742 restarted.get("signed/catalog-only").unwrap().name,
5743 "signed-catalog-only",
5744 "a user row must not shadow a signature-verified project exact id"
5745 );
5746 }
5747
5748 #[test]
5749 fn empty_user_config_save_clears_stale_rows() {
5750 let tmp = TempDir::new().unwrap();
5751 let models_dir = tmp.path().join("models");
5752 let config_path = tmp.path().join("models.json");
5753 let stale = test_generate_schema(
5754 "user/stale",
5755 "stale",
5756 ModelSource::RemoteApi {
5757 endpoint: "https://stale.example/v1".into(),
5758 api_key_env: "STALE_USER_MODEL_TEST_KEY".into(),
5759 api_key_envs: vec![],
5760 api_version: None,
5761 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5762 },
5763 );
5764 std::fs::write(
5765 &config_path,
5766 serde_json::to_vec_pretty(&vec![stale]).unwrap(),
5767 )
5768 .unwrap();
5769
5770 UnifiedRegistry::new_empty(models_dir)
5771 .save_user_config()
5772 .unwrap();
5773
5774 let saved: Vec<ModelSchema> =
5775 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5776 assert!(
5777 saved.is_empty(),
5778 "saving an empty user set must overwrite stale models.json rows"
5779 );
5780 }
5781
5782 #[test]
5783 fn unregister_then_save_removes_the_user_row_from_disk() {
5784 let tmp = TempDir::new().unwrap();
5785 let models_dir = tmp.path().join("models");
5786 let config_path = tmp.path().join("models.json");
5787 let mut registry = UnifiedRegistry::new_empty(models_dir);
5788 registry.register_project_model(test_generate_schema(
5789 "signed/not-user-removable",
5790 "not-user-removable",
5791 ModelSource::RemoteApi {
5792 endpoint: "https://catalog.example/v1".into(),
5793 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5794 api_key_envs: vec![],
5795 api_version: None,
5796 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5797 },
5798 ));
5799 assert!(
5800 registry
5801 .unregister_user_model("signed/not-user-removable")
5802 .is_none(),
5803 "the user boundary cannot unregister an untracked catalog row"
5804 );
5805 assert!(registry.get("signed/not-user-removable").is_some());
5806 registry.register_user_model(test_generate_schema(
5807 "user/removable",
5808 "removable",
5809 ModelSource::RemoteApi {
5810 endpoint: "https://user.example/v1".into(),
5811 api_key_env: "REMOVABLE_USER_MODEL_TEST_KEY".into(),
5812 api_key_envs: vec![],
5813 api_version: None,
5814 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5815 },
5816 ));
5817 registry.save_user_config().unwrap();
5818 assert!(registry.unregister_user_model("user/removable").is_some());
5819 registry.save_user_config().unwrap();
5820
5821 let saved: Vec<ModelSchema> =
5822 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5823 assert!(saved.is_empty());
5824 }
5825
5826 #[test]
5838 fn a_registration_written_under_car_home_is_the_file_the_registry_reads() {
5839 let _environment = crate::openrouter::test_environment_scope();
5840 let prior = std::env::var_os(car_home::ENV_VAR);
5841
5842 let state_root = TempDir::new().unwrap();
5843 let weights = TempDir::new().unwrap();
5846 let models_dir = weights.path().join("models");
5847 std::fs::create_dir_all(&models_dir).unwrap();
5848
5849 unsafe { std::env::set_var(car_home::ENV_VAR, state_root.path()) };
5850
5851 let write_path = user_config_path().expect("CAR_HOME must resolve a models.json path");
5853 assert_eq!(write_path, state_root.path().join(USER_MODELS_FILE));
5854 let registered = test_generate_schema(
5855 "user/relocated-daemon-model",
5856 "relocated-daemon-model",
5857 ModelSource::RemoteApi {
5858 endpoint: "https://relocated.example/v1".into(),
5859 api_key_env: "RELOCATED_DAEMON_MODEL_TEST_KEY".into(),
5860 api_key_envs: vec![],
5861 api_version: None,
5862 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5863 },
5864 );
5865 std::fs::write(
5866 &write_path,
5867 serde_json::to_vec_pretty(&vec![registered]).unwrap(),
5868 )
5869 .unwrap();
5870
5871 let registry = UnifiedRegistry::new(models_dir.clone());
5873
5874 match prior {
5875 Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
5876 None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
5877 }
5878
5879 assert!(
5880 registry.get("user/relocated-daemon-model").is_some(),
5881 "the registry must load the models.json that `models.register` wrote; \
5882 it looked at {} instead",
5883 registry.user_config_path.display(),
5884 );
5885 assert_eq!(registry.user_config_path, write_path);
5886 assert!(
5887 !weights.path().join(USER_MODELS_FILE).exists(),
5888 "nothing may be written beside the shared weights cache",
5889 );
5890 }
5891
5892 #[test]
5893 fn ready_without_download_is_strict_for_local_model_files() {
5894 let tmp = TempDir::new().unwrap();
5895 let models = tmp.path().join("models");
5896 let mut reg = UnifiedRegistry::new_empty(models.clone());
5897 reg.register(test_generate_schema(
5898 "local/test",
5899 "TestLocal",
5900 ModelSource::Local {
5901 hf_repo: "example/repo".into(),
5902 hf_filename: "model.gguf".into(),
5903 tokenizer_repo: "example/repo".into(),
5904 },
5905 ));
5906
5907 assert_eq!(reg.ready_without_download("local/test"), Some(false));
5908
5909 let dir = models.join("TestLocal");
5910 std::fs::create_dir_all(&dir).unwrap();
5911 std::fs::write(dir.join("model.gguf"), b"weights").unwrap();
5912 assert_eq!(
5913 reg.ready_without_download("local/test"),
5914 Some(false),
5915 "tokenizer is required too"
5916 );
5917 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
5918 assert_eq!(reg.ready_without_download("local/test"), Some(true));
5919 }
5920
5921 #[test]
5922 fn ready_without_download_rejects_mlx_config_only_stub() {
5923 let tmp = TempDir::new().unwrap();
5924 let models = tmp.path().join("models");
5925 let mut reg = UnifiedRegistry::new_empty(models.clone());
5926 reg.register(test_generate_schema(
5927 "mlx/test",
5928 "TestMlx",
5929 ModelSource::Mlx {
5930 hf_repo: "example/repo".into(),
5931 hf_weight_file: None,
5932 },
5933 ));
5934
5935 let dir = models.join("TestMlx");
5936 std::fs::create_dir_all(&dir).unwrap();
5937 std::fs::write(dir.join("config.json"), "{}").unwrap();
5938 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
5939 assert_eq!(
5940 reg.ready_without_download("mlx/test"),
5941 Some(false),
5942 "config/tokenizer stubs must not start assistant inference"
5943 );
5944
5945 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
5946 assert_eq!(reg.ready_without_download("mlx/test"), Some(true));
5947 }
5948
5949 fn write_mlx_dir(root: &Path, name: &str, model_type: &str) {
5950 let dir = root.join(name);
5951 std::fs::create_dir_all(&dir).unwrap();
5952 std::fs::write(
5953 dir.join("config.json"),
5954 serde_json::json!({
5955 "model_type": model_type,
5956 "max_position_embeddings": 40_960,
5957 "quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" },
5958 })
5959 .to_string(),
5960 )
5961 .unwrap();
5962 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
5963 }
5964
5965 struct ScopedEnvVar {
5966 name: &'static str,
5967 previous: Option<std::ffi::OsString>,
5968 }
5969
5970 impl ScopedEnvVar {
5971 fn set(name: &'static str, value: &Path) -> Self {
5972 let previous = std::env::var_os(name);
5973 unsafe { std::env::set_var(name, value) };
5976 Self { name, previous }
5977 }
5978
5979 fn unset(name: &'static str) -> Self {
5980 let previous = std::env::var_os(name);
5981 unsafe { std::env::remove_var(name) };
5983 Self { name, previous }
5984 }
5985 }
5986
5987 impl Drop for ScopedEnvVar {
5988 fn drop(&mut self) {
5989 unsafe {
5992 match self.previous.take() {
5993 Some(value) => std::env::set_var(self.name, value),
5994 None => std::env::remove_var(self.name),
5995 }
5996 }
5997 }
5998 }
5999
6000 #[derive(Default)]
6001 struct AcquisitionRecorder {
6002 events: std::sync::Mutex<Vec<DownloadEvent>>,
6003 replace_dir_on_started: std::sync::Mutex<Option<PathBuf>>,
6004 }
6005
6006 impl AcquisitionRecorder {
6007 fn replacing_dir_on_started(path: PathBuf) -> Self {
6008 Self {
6009 events: std::sync::Mutex::new(Vec::new()),
6010 replace_dir_on_started: std::sync::Mutex::new(Some(path)),
6011 }
6012 }
6013
6014 fn events(&self) -> Vec<DownloadEvent> {
6015 self.events.lock().unwrap().clone()
6016 }
6017 }
6018
6019 impl crate::download::DownloadProgress for AcquisitionRecorder {
6020 fn on_event(&self, event: &DownloadEvent) {
6021 self.events.lock().unwrap().push(event.clone());
6022 if matches!(event, DownloadEvent::Started { .. }) {
6023 if let Some(path) = self.replace_dir_on_started.lock().unwrap().take() {
6024 std::fs::remove_dir_all(&path).unwrap();
6025 std::fs::write(path, b"make create_dir_all fail before any network access")
6026 .unwrap();
6027 }
6028 }
6029 }
6030 }
6031
6032 fn started_count(events: &[DownloadEvent]) -> usize {
6033 events
6034 .iter()
6035 .filter(|event| matches!(event, DownloadEvent::Started { .. }))
6036 .count()
6037 }
6038
6039 fn mlx_schema(id: &str, name: &str, hf_repo: &str) -> ModelSchema {
6040 test_generate_schema(
6041 id,
6042 name,
6043 ModelSource::Mlx {
6044 hf_repo: hf_repo.into(),
6045 hf_weight_file: None,
6046 },
6047 )
6048 }
6049
6050 #[test]
6051 fn installing_a_fetched_file_replaces_only_unusable_destinations() {
6052 let tmp = TempDir::new().unwrap();
6053 let src = tmp.path().join("fetched");
6054 std::fs::write(&src, b"fetched bytes").unwrap();
6055
6056 let missing = tmp.path().join("missing");
6057 install_fetched_file(&src, &missing).unwrap();
6058 assert_eq!(std::fs::read(&missing).unwrap(), b"fetched bytes");
6059
6060 let usable = tmp.path().join("usable");
6061 std::fs::write(&usable, b"keep these bytes").unwrap();
6062 let usable_before = std::fs::symlink_metadata(&usable).unwrap();
6063 let modified_before = usable_before.modified().unwrap();
6064 #[cfg(unix)]
6065 let inode_before = {
6066 use std::os::unix::fs::MetadataExt;
6067 usable_before.ino()
6068 };
6069 install_fetched_file(&src, &usable).unwrap();
6070 let usable_after = std::fs::symlink_metadata(&usable).unwrap();
6071 assert_eq!(std::fs::read(&usable).unwrap(), b"keep these bytes");
6072 assert_eq!(usable_after.modified().unwrap(), modified_before);
6073 #[cfg(unix)]
6074 {
6075 use std::os::unix::fs::MetadataExt;
6076 assert_eq!(usable_after.ino(), inode_before);
6077 }
6078
6079 let zero_byte = tmp.path().join("zero-byte");
6080 std::fs::write(&zero_byte, b"").unwrap();
6081 install_fetched_file(&src, &zero_byte).unwrap();
6082 assert_eq!(std::fs::read(&zero_byte).unwrap(), b"fetched bytes");
6083
6084 #[cfg(unix)]
6085 {
6086 let dangling = tmp.path().join("dangling");
6087 std::os::unix::fs::symlink(tmp.path().join("absent"), &dangling).unwrap();
6088 assert!(std::fs::symlink_metadata(&dangling).unwrap().is_symlink());
6089 install_fetched_file(&src, &dangling).unwrap();
6090 assert_eq!(std::fs::read(&dangling).unwrap(), b"fetched bytes");
6091 }
6092 }
6093
6094 #[cfg(unix)]
6097 #[tokio::test]
6098 async fn a_zero_byte_flux_auxiliary_file_is_not_accepted_as_present() {
6099 let _environment = crate::openrouter::test_environment_scope_async().await;
6100 let tmp = TempDir::new().unwrap();
6101 let home = tmp.path().join("home");
6102 let _home = ScopedEnvVar::set("HOME", &home);
6103 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6104 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6105 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6106
6107 let cached_auxiliary = tmp
6111 .path()
6112 .join("hf-home/hub")
6113 .join("models--Freepik--flux.1-lite-8B")
6114 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
6115 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
6116 std::fs::write(&cached_auxiliary, b"repaired tokenizer").unwrap();
6117 let refs = tmp
6118 .path()
6119 .join("hf-home/hub/models--Freepik--flux.1-lite-8B/refs");
6120 std::fs::create_dir_all(&refs).unwrap();
6121 std::fs::write(refs.join("main"), b"fixture").unwrap();
6122
6123 let models = tmp.path().join("models");
6124 let name = "Flux-1.lite-8B-MLX-Q4";
6125 write_mlx_dir(&models, name, "flux");
6126 let auxiliary = models.join(name).join("tokenizer_2/tokenizer.json");
6127 std::fs::create_dir_all(auxiliary.parent().unwrap()).unwrap();
6128 std::fs::write(&auxiliary, b"").unwrap();
6129 let mut reg = UnifiedRegistry::new_empty(models.clone());
6130 reg.register(mlx_schema(
6131 "mlx/flux-zero-byte-aux",
6132 name,
6133 "mlx-community/Flux-1.lite-8B-MLX-Q4",
6134 ));
6135 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6136 let sink = ProgressSink::new(recorder.clone());
6137
6138 let result = reg
6139 .acquire_and_ensure("mlx/flux-zero-byte-aux", &sink, false, None)
6140 .await;
6141 let events = recorder.events();
6142 assert!(matches!(
6143 events.first(),
6144 Some(DownloadEvent::Started { .. })
6145 ));
6146 assert_eq!(started_count(&events), 1);
6147 assert!(
6148 result.is_err() || crate::download::cache_file_usable(&auxiliary),
6149 "acquisition must replace the zero-byte auxiliary file or report failure"
6150 );
6151 if result.is_ok() {
6152 assert_eq!(std::fs::read(auxiliary).unwrap(), b"repaired tokenizer");
6153 }
6154 assert!(
6157 !tmp.path()
6158 .join("model-management")
6159 .join(crate::retire::PROVENANCE_FILE)
6160 .exists(),
6161 "reusing a cached file must not record it as a CAR download"
6162 );
6163 }
6164
6165 #[cfg(unix)]
6168 #[tokio::test]
6169 async fn managed_flux_missing_auxiliary_does_not_fall_through_to_hf_snapshot() {
6170 let _environment = crate::openrouter::test_environment_scope_async().await;
6171 let tmp = TempDir::new().unwrap();
6172 let home = tmp.path().join("home");
6173 let hf_home = tmp.path().join("hf-home");
6174 let _home = ScopedEnvVar::set("HOME", &home);
6175 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
6176 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6177 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6178
6179 let cached_auxiliary = hf_home
6183 .join("hub")
6184 .join("models--Freepik--flux.1-lite-8B")
6185 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
6186 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
6187 std::fs::write(&cached_auxiliary, b"managed repair tokenizer").unwrap();
6188 let refs = hf_home.join("hub/models--Freepik--flux.1-lite-8B/refs");
6189 std::fs::create_dir_all(&refs).unwrap();
6190 std::fs::write(refs.join("main"), b"fixture").unwrap();
6191
6192 let models = tmp.path().join("models");
6193 let name = "Flux-1.lite-8B-MLX-Q4";
6194 let managed = models.join(name);
6195 write_mlx_dir(&models, name, "flux");
6196 let managed_auxiliary = managed.join("tokenizer_2/tokenizer.json");
6197
6198 let snapshot = hf_home
6201 .join("hub/models--mlx-community--Flux-1.lite-8B-MLX-Q4")
6202 .join("snapshots/fixture");
6203 write_complete_mlx_snapshot(&snapshot);
6204 let snapshot_auxiliary = snapshot.join("tokenizer_2/tokenizer.json");
6205 std::fs::create_dir_all(snapshot_auxiliary.parent().unwrap()).unwrap();
6206 std::fs::write(&snapshot_auxiliary, b"snapshot tokenizer").unwrap();
6207
6208 let id = "mlx/flux-managed-precedence";
6209 let mut reg = UnifiedRegistry::new_empty(models);
6210 reg.register(mlx_schema(id, name, "mlx-community/Flux-1.lite-8B-MLX-Q4"));
6211 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6212 let sink = ProgressSink::new(recorder.clone());
6213
6214 let path = reg
6215 .acquire_and_ensure(id, &sink, false, None)
6216 .await
6217 .unwrap();
6218
6219 assert_eq!(path, managed);
6220 assert_eq!(
6221 std::fs::read(managed_auxiliary).unwrap(),
6222 b"managed repair tokenizer"
6223 );
6224 let events = recorder.events();
6225 assert!(matches!(
6226 events.first(),
6227 Some(DownloadEvent::Started { .. })
6228 ));
6229 assert_eq!(started_count(&events), 1);
6230 }
6231
6232 #[tokio::test]
6233 async fn reusing_a_complete_managed_mlx_dir_emits_no_acquisition_lifecycle() {
6234 let _environment = crate::openrouter::test_environment_scope_async().await;
6235 let tmp = TempDir::new().unwrap();
6236 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6237 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6238 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6239 let models = tmp.path().join("models");
6240 let mut reg = UnifiedRegistry::new_empty(models.clone());
6241 reg.register(mlx_schema(
6242 "mlx/reuse-complete",
6243 "Reuse-Complete-MLX",
6244 "example/reuse-complete",
6245 ));
6246 write_mlx_dir(&models, "Reuse-Complete-MLX", "qwen3");
6247 std::fs::write(
6248 models.join("Reuse-Complete-MLX").join("tokenizer.json"),
6249 b"{}",
6250 )
6251 .unwrap();
6252 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6253 let sink = ProgressSink::new(recorder.clone());
6254
6255 let path = reg
6256 .ensure_local_with_progress("mlx/reuse-complete", &sink)
6257 .await
6258 .unwrap();
6259
6260 assert_eq!(path, models.join("Reuse-Complete-MLX"));
6261 assert!(recorder.events().is_empty());
6262 }
6263
6264 #[tokio::test]
6265 async fn reusing_a_managed_mlx_dir_without_tokenizer_json_emits_no_lifecycle() {
6266 let _environment = crate::openrouter::test_environment_scope_async().await;
6267 let tmp = TempDir::new().unwrap();
6268 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6269 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6270 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6271 let models = tmp.path().join("models");
6272 let mut reg = UnifiedRegistry::new_empty(models.clone());
6273 reg.register(mlx_schema(
6274 "mlx/reuse-no-tokenizer",
6275 "Reuse-No-Tokenizer-MLX",
6276 "example/reuse-no-tokenizer",
6277 ));
6278 write_mlx_dir(&models, "Reuse-No-Tokenizer-MLX", "qwen3");
6279 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6280 let sink = ProgressSink::new(recorder.clone());
6281
6282 let path = reg
6283 .ensure_local_with_progress("mlx/reuse-no-tokenizer", &sink)
6284 .await
6285 .unwrap();
6286
6287 assert_eq!(path, models.join("Reuse-No-Tokenizer-MLX"));
6288 assert!(recorder.events().is_empty());
6289 }
6290
6291 #[tokio::test]
6292 async fn reusing_a_complete_hf_snapshot_emits_no_acquisition_lifecycle() {
6293 let _environment = crate::openrouter::test_environment_scope_async().await;
6294 let tmp = TempDir::new().unwrap();
6295 let hf_home = tmp.path().join("hf-home");
6296 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
6297 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6298 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6299 let models = tmp.path().join("models");
6300 let repo = "example/reuse-hf-snapshot";
6301 let snapshot = hf_home
6302 .join("hub")
6303 .join("models--example--reuse-hf-snapshot")
6304 .join("snapshots")
6305 .join("fixture");
6306 write_complete_mlx_snapshot(&snapshot);
6307 let mut reg = UnifiedRegistry::new_empty(models);
6308 reg.register(mlx_schema("mlx/reuse-hf", "Reuse-HF-MLX", repo));
6309 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6310 let sink = ProgressSink::new(recorder.clone());
6311
6312 let path = reg
6313 .ensure_local_with_progress("mlx/reuse-hf", &sink)
6314 .await
6315 .unwrap();
6316
6317 assert_eq!(path, snapshot);
6318 assert!(recorder.events().is_empty());
6319 }
6320
6321 #[tokio::test]
6322 async fn a_zero_byte_managed_config_json_starts_acquisition() {
6323 let _environment = crate::openrouter::test_environment_scope_async().await;
6324 let tmp = TempDir::new().unwrap();
6325 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6326 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6327 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6328 let models = tmp.path().join("models");
6329 let name = "Zero-Byte-Config-MLX";
6330 write_mlx_dir(&models, name, "qwen3");
6331 let model_dir = models.join(name);
6332 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
6333 std::fs::write(model_dir.join("config.json"), b"").unwrap();
6334 let mut reg = UnifiedRegistry::new_empty(models);
6335 reg.register(mlx_schema(
6336 "mlx/zero-byte-config",
6337 name,
6338 "example/zero-byte-config",
6339 ));
6340 let recorder =
6341 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
6342 let sink = ProgressSink::new(recorder.clone());
6343
6344 let result = reg
6345 .ensure_local_with_progress("mlx/zero-byte-config", &sink)
6346 .await;
6347
6348 let events = recorder.events();
6349 assert!(
6350 matches!(events.first(), Some(DownloadEvent::Started { .. })),
6351 "weights beside a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
6352 );
6353 assert_eq!(started_count(&events), 1);
6354 assert!(result.is_err());
6355 }
6356
6357 #[tokio::test]
6358 async fn a_zero_byte_hf_snapshot_config_json_starts_acquisition() {
6359 let _environment = crate::openrouter::test_environment_scope_async().await;
6360 let tmp = TempDir::new().unwrap();
6361 let hf_home = tmp.path().join("hf-home");
6362 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
6363 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6364 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6365 let models = tmp.path().join("models");
6366 let name = "Zero-Byte-Snapshot-Config-MLX";
6367 let repo = "example/zero-byte-snapshot-config";
6368 let snapshot = hf_home
6369 .join("hub")
6370 .join("models--example--zero-byte-snapshot-config")
6371 .join("snapshots")
6372 .join("fixture");
6373 write_complete_mlx_snapshot(&snapshot);
6374 std::fs::write(snapshot.join("config.json"), b"").unwrap();
6375 let model_dir = models.join(name);
6378 std::fs::create_dir_all(&model_dir).unwrap();
6379 let mut reg = UnifiedRegistry::new_empty(models);
6380 reg.register(mlx_schema("mlx/zero-byte-snapshot-config", name, repo));
6381 let recorder =
6382 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
6383 let sink = ProgressSink::new(recorder.clone());
6384
6385 let result = reg
6386 .ensure_local_with_progress("mlx/zero-byte-snapshot-config", &sink)
6387 .await;
6388
6389 let events = recorder.events();
6390 assert!(
6391 matches!(events.first(), Some(DownloadEvent::Started { .. })),
6392 "a snapshot with a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
6393 );
6394 assert_eq!(started_count(&events), 1);
6395 assert!(result.is_err());
6396 }
6397
6398 #[cfg(unix)]
6399 #[tokio::test]
6400 async fn an_oversized_installed_model_is_reused_below_the_disk_threshold() {
6401 let _environment = crate::openrouter::test_environment_scope_async().await;
6402 let tmp = TempDir::new().unwrap();
6403 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6404 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6405 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6406 let models = tmp.path().join("models");
6407 let mut schema = mlx_schema(
6408 "mlx/reuse-oversized",
6409 "Reuse-Oversized-MLX",
6410 "example/reuse-oversized",
6411 );
6412 schema.cost.size_mb = Some(1_000_000_000);
6415 let mut reg = UnifiedRegistry::new_empty(models.clone());
6416 reg.register(schema);
6417 write_mlx_dir(&models, "Reuse-Oversized-MLX", "qwen3");
6418 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6419 let sink = ProgressSink::new(recorder.clone());
6420
6421 let path = reg
6422 .ensure_local_with_progress("mlx/reuse-oversized", &sink)
6423 .await
6424 .unwrap();
6425
6426 assert_eq!(path, models.join("Reuse-Oversized-MLX"));
6427 assert!(recorder.events().is_empty());
6428 }
6429
6430 #[tokio::test]
6431 async fn a_missing_shard_starts_acquisition_exactly_once_after_the_preflight() {
6432 let _environment = crate::openrouter::test_environment_scope_async().await;
6433 let tmp = TempDir::new().unwrap();
6434 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6435 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6436 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6437 let models = tmp.path().join("models");
6438 let model_dir = models.join("Missing-Shard-MLX");
6439 std::fs::create_dir_all(&model_dir).unwrap();
6440 std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
6441 std::fs::write(
6442 model_dir.join("model.safetensors.index.json"),
6443 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
6444 )
6445 .unwrap();
6446 std::fs::write(model_dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
6447 let mut reg = UnifiedRegistry::new_empty(models);
6448 reg.register(mlx_schema(
6449 "mlx/missing-shard-lifecycle",
6450 "Missing-Shard-MLX",
6451 "example/missing-shard",
6452 ));
6453 let recorder =
6454 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
6455 let sink = ProgressSink::new(recorder.clone());
6456
6457 assert!(reg
6458 .ensure_local_with_progress("mlx/missing-shard-lifecycle", &sink)
6459 .await
6460 .is_err());
6461 let events = recorder.events();
6462 assert!(matches!(
6463 events.first(),
6464 Some(DownloadEvent::Started { .. })
6465 ));
6466 assert_eq!(started_count(&events), 1);
6467 }
6468
6469 #[tokio::test]
6470 async fn a_flux_dir_missing_its_auxiliary_tokenizer_keeps_the_lifecycle() {
6471 let _environment = crate::openrouter::test_environment_scope_async().await;
6472 let tmp = TempDir::new().unwrap();
6473 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
6474 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
6475 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
6476 let models = tmp.path().join("models");
6477 let name = "Flux-1.lite-8B-MLX-Q4";
6478 write_mlx_dir(&models, name, "flux");
6479 std::fs::write(models.join(name).join("tokenizer_2"), b"not a directory").unwrap();
6480 let mut reg = UnifiedRegistry::new_empty(models);
6481 reg.register(mlx_schema(
6482 "mlx/flux-missing-aux",
6483 name,
6484 "mlx-community/Flux-1.lite-8B-MLX-Q4",
6485 ));
6486 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6487 let sink = ProgressSink::new(recorder.clone());
6488
6489 assert!(reg
6490 .ensure_local_with_progress("mlx/flux-missing-aux", &sink)
6491 .await
6492 .is_err());
6493 let events = recorder.events();
6494 assert!(matches!(
6495 events.first(),
6496 Some(DownloadEvent::Started { .. })
6497 ));
6498 assert_eq!(started_count(&events), 1);
6499 }
6500
6501 #[tokio::test]
6502 async fn force_bypasses_reuse() {
6503 let _environment = crate::openrouter::test_environment_scope_async().await;
6504 let tmp = TempDir::new().unwrap();
6505 let models = tmp.path().join("models");
6506 let name = "Force-Local";
6507 let model_dir = models.join(name);
6508 std::fs::create_dir_all(&model_dir).unwrap();
6509 std::fs::write(model_dir.join("model.gguf"), b"weights").unwrap();
6510 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
6511 let mut reg = UnifiedRegistry::new_empty(models);
6512 reg.register(test_generate_schema(
6513 "local/force-lifecycle",
6514 name,
6515 ModelSource::Local {
6516 hf_repo: String::new(),
6517 hf_filename: "model.gguf".into(),
6518 tokenizer_repo: String::new(),
6519 },
6520 ));
6521 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6522 let sink = ProgressSink::new(recorder.clone());
6523
6524 assert!(reg
6525 .acquire_and_ensure("local/force-lifecycle", &sink, true, None)
6526 .await
6527 .is_err());
6528 let events = recorder.events();
6529 assert!(matches!(
6530 events.first(),
6531 Some(DownloadEvent::Started { .. })
6532 ));
6533 assert_eq!(started_count(&events), 1);
6534 }
6535
6536 #[tokio::test]
6537 async fn staged_pulls_keep_the_lifecycle() {
6538 let _environment = crate::openrouter::test_environment_scope_async().await;
6539 let tmp = TempDir::new().unwrap();
6540 let models = tmp.path().join("models");
6541 let canonical = models.join("Staged-Local");
6542 std::fs::create_dir_all(&canonical).unwrap();
6543 std::fs::write(canonical.join("model.gguf"), b"canonical weights").unwrap();
6544 std::fs::write(canonical.join("tokenizer.json"), b"{}").unwrap();
6545 let mut reg = UnifiedRegistry::new_empty(models);
6546 reg.register(test_generate_schema(
6547 "local/staged-lifecycle",
6548 "Staged-Local",
6549 ModelSource::Local {
6550 hf_repo: String::new(),
6551 hf_filename: "model.gguf".into(),
6552 tokenizer_repo: String::new(),
6553 },
6554 ));
6555 let staging = tmp.path().join("staging");
6556 std::fs::create_dir_all(&staging).unwrap();
6557 std::fs::write(staging.join("model.gguf"), b"weights").unwrap();
6558 std::fs::write(staging.join("tokenizer.json"), b"{}").unwrap();
6559 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
6560 let sink = ProgressSink::new(recorder.clone());
6561
6562 let path = reg
6563 .acquire_and_ensure("local/staged-lifecycle", &sink, false, Some(&staging))
6564 .await
6565 .unwrap();
6566 assert_eq!(path, staging);
6567 let events = recorder.events();
6568 assert!(matches!(
6569 events.first(),
6570 Some(DownloadEvent::Started { .. })
6571 ));
6572 assert_eq!(started_count(&events), 1);
6573 }
6574
6575 #[test]
6576 fn synthesize_local_schema_classifies_by_name_and_arch() {
6577 let tmp = TempDir::new().unwrap();
6578 let root = tmp.path();
6579
6580 write_mlx_dir(root, "MyCustom-Qwen3-7B", "qwen3");
6581 let gen = synthesize_local_schema("MyCustom-Qwen3-7B", &root.join("MyCustom-Qwen3-7B"))
6582 .expect("text LLM should be recognized");
6583 assert_eq!(
6584 gen.capabilities,
6585 vec![
6586 ModelCapability::Generate,
6587 ModelCapability::Code,
6588 ModelCapability::Reasoning
6589 ]
6590 );
6591 assert_eq!(gen.context_length, 40_960);
6592 assert_eq!(gen.provider, "local");
6593 assert!(matches!(gen.source, ModelSource::Mlx { .. }));
6594 assert_eq!(gen.family, "local");
6600 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6601 assert!(!crate::InferenceEngine::supports_native_mlx(&gen));
6602
6603 write_mlx_dir(root, "Some-Embedding-0.6B", "qwen3");
6604 let emb = synthesize_local_schema("Some-Embedding-0.6B", &root.join("Some-Embedding-0.6B"))
6605 .expect("embedding model recognized");
6606 assert_eq!(emb.capabilities, vec![ModelCapability::Embed]);
6607
6608 write_mlx_dir(root, "Mystery-Net", "some_unknown_arch");
6610 assert!(synthesize_local_schema("Mystery-Net", &root.join("Mystery-Net")).is_none());
6611
6612 write_mlx_dir(root, "silero-vad-v6-mlx", "qwen3");
6614 assert!(
6615 synthesize_local_schema("silero-vad-v6-mlx", &root.join("silero-vad-v6-mlx")).is_none()
6616 );
6617
6618 std::fs::create_dir_all(root.join("empty")).unwrap();
6620 assert!(synthesize_local_schema("empty", &root.join("empty")).is_none());
6621 }
6622
6623 #[test]
6629 fn a_scanned_gguf_directory_diagnoses_instead_of_downloading_from_nowhere() {
6630 let _environment = crate::openrouter::test_environment_scope();
6631 let tmp = TempDir::new().unwrap();
6632 let models = tmp.path().join("models");
6633 let dir = models.join("Dropped-In-Llama");
6634 std::fs::create_dir_all(&dir).unwrap();
6635 std::fs::write(dir.join("Llama-3-8B-Q4_K_M.gguf"), b"weights").unwrap();
6637
6638 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6641 let err = tokio::runtime::Runtime::new()
6642 .unwrap()
6643 .block_on(reg.ensure_local("Dropped-In-Llama"))
6644 .expect_err("an unloadable layout must not report success");
6645 let err = err.to_string();
6646
6647 assert!(err.contains("model.gguf"), "must name what it reads: {err}");
6648 assert!(
6649 err.contains("tokenizer.json"),
6650 "must name the missing tokenizer too: {err}"
6651 );
6652 assert!(
6653 !err.contains("huggingface.co//"),
6654 "must not have tried to fetch from an empty repo: {err}"
6655 );
6656 }
6657
6658 #[test]
6659 fn discovery_registers_uncatalogued_local_model() {
6660 let _environment = crate::openrouter::test_environment_scope();
6665 let tmp = TempDir::new().unwrap();
6666 let models = tmp.path().join("models");
6667 std::fs::create_dir_all(&models).unwrap();
6668 write_mlx_dir(&models, "Totally-Custom-Llama-3B", "llama");
6669
6670 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6671 let found = reg
6672 .list()
6673 .into_iter()
6674 .find(|m| m.name == "Totally-Custom-Llama-3B");
6675 assert!(
6676 found.is_some(),
6677 "uncatalogued on-disk model should be registered"
6678 );
6679 assert!(found.unwrap().tags.iter().any(|t| t == "auto-discovered"));
6680 }
6681
6682 #[test]
6683 fn explicit_user_row_is_not_pruned_by_auto_discovery_tag() {
6684 let _environment = crate::openrouter::test_environment_scope();
6685 let tmp = TempDir::new().unwrap();
6686 let models = tmp.path().join("models");
6687 let model_dir = models.join("Explicit-Custom-Llama");
6688 write_mlx_dir(&models, "Explicit-Custom-Llama", "llama");
6689
6690 let mut registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6691 let model_id = "local/explicit-custom-llama";
6692 let schema = registry.get(model_id).cloned().unwrap();
6693 assert!(schema.tags.iter().any(|tag| tag == "auto-discovered"));
6694
6695 registry.register_user_model(schema);
6696 std::fs::remove_dir_all(model_dir).unwrap();
6697 registry.prune_missing_on_disk_models();
6698
6699 assert!(
6700 registry.get(model_id).is_some(),
6701 "explicit registration provenance must outrank a user-controlled tag"
6702 );
6703 }
6704
6705 #[test]
6710 fn colliding_directory_does_not_claim_a_registered_row() {
6711 let tmp = TempDir::new().unwrap();
6712 let models = tmp.path().join("models");
6713 write_mlx_dir(&models, "Collide-User-Llama", "llama");
6714 write_mlx_dir(&models, "Collide-Project-Llama", "llama");
6715 let mut registry = UnifiedRegistry::new_empty(models.clone());
6716
6717 let mut user =
6718 synthesize_local_schema("Collide-User-Llama", &models.join("Collide-User-Llama"))
6719 .unwrap();
6720 user.name = "Configured-Elsewhere-User".into();
6721 let user_id = user.id.clone();
6722 registry.register_user_model(user);
6723
6724 let mut project = synthesize_local_schema(
6725 "Collide-Project-Llama",
6726 &models.join("Collide-Project-Llama"),
6727 )
6728 .unwrap();
6729 project.name = "Configured-Elsewhere-Project".into();
6730 let project_id = project.id.clone();
6731 assert!(registry.register_project_model(project));
6732
6733 registry.discover_on_disk_models();
6734 assert_eq!(
6735 registry.get(&user_id).map(|m| m.name.as_str()),
6736 Some("Configured-Elsewhere-User"),
6737 "discovery must not replace a registered row"
6738 );
6739
6740 std::fs::remove_dir_all(models.join("Collide-User-Llama")).unwrap();
6741 std::fs::remove_dir_all(models.join("Collide-Project-Llama")).unwrap();
6742 registry.prune_missing_on_disk_models();
6743
6744 assert!(
6745 registry.get(&user_id).is_some(),
6746 "removing a colliding directory must not erase a user row"
6747 );
6748 assert!(
6749 registry.get(&project_id).is_some(),
6750 "removing a colliding directory must not erase a project row"
6751 );
6752 }
6753
6754 #[test]
6755 fn signed_catalog_cannot_shadow_builtin_exact_id() {
6756 let _environment = crate::openrouter::test_environment_scope();
6761 let tmp = TempDir::new().unwrap();
6765 let models_dir = tmp.path().join("models");
6766
6767 let builtin = builtin_catalog();
6768 let mut overriding = builtin.first().expect("a built-in model").clone();
6769 let target_id = overriding.id.clone();
6770 overriding.name = "REPLACED-BY-CATALOG".into();
6771
6772 let (verified, public_key) = crate::catalog::signed_test_catalog(
6773 crate::catalog::CatalogDoc {
6774 revoked: Vec::new(),
6775 version: 1,
6776 models: vec![overriding],
6777 },
6778 51,
6779 );
6780 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
6781
6782 let reg = UnifiedRegistry::new_with_catalog_public_key(
6784 tmp.path().to_path_buf(),
6785 models_dir,
6786 Some(public_key.as_str()),
6787 );
6788 assert_eq!(
6789 reg.get(&target_id).map(|m| m.name.as_str()),
6790 Some(builtin.first().unwrap().name.as_str()),
6791 "a signed cache row must not replace a builtin exact id"
6792 );
6793 }
6794
6795 #[test]
6796 fn user_model_cannot_shadow_project_owned_exact_id() {
6797 let mut reg = test_registry();
6798 let original = builtin_catalog().first().expect("a builtin").clone();
6799 let mut forged = original.clone();
6800 forged.name = "USER-SHADOW".into();
6801
6802 reg.register_user_model(forged);
6803
6804 assert_eq!(
6805 reg.get(&original.id).map(|model| model.name.as_str()),
6806 Some(original.name.as_str()),
6807 "a user row must not replace a project-owned exact id"
6808 );
6809 }
6810
6811 #[test]
6812 fn legacy_unsigned_catalog_cache_cannot_replace_builtin() {
6813 let _environment = crate::openrouter::test_environment_scope();
6818 let tmp = TempDir::new().unwrap();
6819 let models_dir = tmp.path().join("models");
6820 let builtin = builtin_catalog();
6821 let original = builtin.first().expect("a built-in model");
6822 let mut forged = original.clone();
6823 forged.name = "FORGED-UNSIGNED-CATALOG".into();
6824 let path = crate::catalog::cache_path(tmp.path());
6825 std::fs::write(
6826 &path,
6827 serde_json::to_vec_pretty(&crate::catalog::CatalogDoc {
6828 revoked: Vec::new(),
6829 version: u64::MAX,
6830 models: vec![forged],
6831 })
6832 .unwrap(),
6833 )
6834 .unwrap();
6835
6836 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
6837 assert_eq!(
6838 reg.get(&original.id).map(|model| model.name.as_str()),
6839 Some(original.name.as_str()),
6840 "legacy unsigned cache JSON must fail closed and preserve the built-in"
6841 );
6842 }
6843
6844 #[test]
6845 fn tampered_signed_managed_row_preserves_builtin() {
6846 let _environment = crate::openrouter::test_environment_scope();
6851 let tmp = TempDir::new().unwrap();
6852 let models_dir = tmp.path().join("models");
6853 let original = builtin_catalog()
6854 .into_iter()
6855 .find(|model| model.id == "parslee/openrouter/frontier-general")
6856 .expect("managed frontier alias");
6857 let mut forged = original.clone();
6858 forged.name = "SIGNED-THEN-TAMPERED-MANAGED".into();
6859 let (verified, public_key) = crate::catalog::signed_test_catalog(
6860 crate::catalog::CatalogDoc {
6861 revoked: Vec::new(),
6862 version: 9,
6863 models: vec![forged],
6864 },
6865 52,
6866 );
6867 let path = crate::catalog::cache_path(tmp.path());
6868 crate::catalog::save_verified(&path, &verified).unwrap();
6869 let cache = std::fs::read_to_string(&path)
6870 .unwrap()
6871 .replace("SIGNED-THEN-TAMPERED-MANAGED", "ATTACKER-MUTATION");
6872 std::fs::write(&path, cache).unwrap();
6873
6874 let reg = UnifiedRegistry::new_with_catalog_public_key(
6875 tmp.path().to_path_buf(),
6876 models_dir,
6877 Some(public_key.as_str()),
6878 );
6879 assert_eq!(
6880 reg.get(&original.id).map(|model| model.name.as_str()),
6881 Some(original.name.as_str()),
6882 "a tampered same-id managed row must fail verification and preserve the builtin"
6883 );
6884 }
6885
6886 #[test]
6887 fn builtin_catalog_loads() {
6888 let reg = test_registry();
6889 let all = reg.list();
6890 assert_eq!(all.len(), builtin_catalog().len());
6891 }
6892
6893 #[test]
6894 fn subscription_billing_lookup_uses_exact_or_implicit_latest_ids() {
6895 let codex_ids: Vec<_> = builtin_catalog()
6896 .into_iter()
6897 .filter(|model| model.id.starts_with("openai-codex/"))
6898 .map(|model| model.id)
6899 .collect();
6900 assert!(!codex_ids.is_empty());
6901 for id in codex_ids {
6902 assert!(is_builtin_subscription_billed(&id), "{id}");
6903 if let Some(untagged) = id.strip_suffix(":latest") {
6904 assert!(is_builtin_subscription_billed(untagged), "{untagged}");
6905 }
6906 }
6907 assert!(!is_builtin_subscription_billed("parslee/reasoning"));
6908 assert!(!is_builtin_subscription_billed("openai/gpt-5.5-2026-04-23"));
6909 assert!(!is_builtin_subscription_billed("unknown/not-a-model"));
6910 }
6911
6912 #[test]
6913 fn shipped_supervised_vllm_models_use_the_managed_source_contract() {
6914 let managed = builtin_catalog()
6915 .into_iter()
6916 .filter(|model| model.id.starts_with("vllm-mlx/"))
6917 .collect::<Vec<_>>();
6918 assert_eq!(managed.len(), 15);
6919 assert!(managed.iter().all(ModelSchema::is_car_managed_vllm_mlx));
6920 assert!(managed.iter().all(ModelSchema::downloads_weights));
6921 }
6922
6923 #[test]
6936 fn mlx_vlm_models_reflect_runtime_availability() {
6937 let reg = test_registry();
6938 let mlx_vlm_models: Vec<&ModelSchema> = reg
6939 .list()
6940 .into_iter()
6941 .filter(|m| m.tags.iter().any(|t| t == "requires-mlx-vlm"))
6942 .collect();
6943 assert!(
6944 !mlx_vlm_models.is_empty(),
6945 "catalog should contain at least one model tagged \
6946 `requires-mlx-vlm` — otherwise this regression has \
6947 nothing to guard"
6948 );
6949
6950 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6951 let expected = crate::backend::mlx_vlm_cli::is_available();
6952 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6953 let expected = false;
6954
6955 for m in mlx_vlm_models {
6956 assert_eq!(
6957 m.available, expected,
6958 "model {} `available` field should reflect \
6959 mlx_vlm CLI presence (expected {expected}, got {})",
6960 m.id, m.available
6961 );
6962 }
6963 }
6964
6965 #[test]
6977 fn mlx_models_unavailable_on_non_mlx_targets() {
6978 let reg = test_registry();
6979 let mlx_models: Vec<&ModelSchema> = reg
6980 .list()
6981 .into_iter()
6982 .filter(|m| {
6983 m.is_mlx()
6984 && !m.tags.iter().any(|t| t == "requires-mlx-vlm")
6989 && !m.tags.contains(&"speech".to_string())
6990 })
6991 .collect();
6992 assert!(
6993 !mlx_models.is_empty(),
6994 "catalog should contain at least one plain MLX model — \
6995 otherwise this F1 regression guard has nothing to guard"
6996 );
6997
6998 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6999 {
7000 let any_available = mlx_models.iter().any(|m| m.available);
7004 assert!(
7005 any_available,
7006 "on macOS arm64 with MLX enabled, at least one plain MLX \
7007 model with hf_repo should be available — none were"
7008 );
7009 }
7010 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7011 {
7012 for m in &mlx_models {
7015 assert!(
7016 !m.available,
7017 "MLX model {} is marked available on a non-MLX target — \
7018 the adaptive router will add it to fallback chains \
7019 and dispatch will fail (Parslee-ai/car#231 §7.1)",
7020 m.id
7021 );
7022 }
7023 }
7024 }
7025
7026 fn is_builtin_model_id_form(id: &str) -> bool {
7031 fn part(s: &str) -> bool {
7032 let mut chars = s.chars();
7033 chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
7034 && chars.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
7035 }
7036 let (body, tag) = match id.split_once(':') {
7037 Some((body, tag)) => (body, Some(tag)),
7038 None => (id, None),
7039 };
7040 let Some((provider, name)) = body.split_once('/') else {
7041 return false;
7042 };
7043 part(provider) && part(name) && tag.is_none_or(part)
7044 }
7045
7046 #[test]
7047 fn builtin_model_ids_follow_the_resolved_model_id_contract() {
7048 for good in [
7049 "anthropic/claude-opus-4-8",
7050 "openai/gpt-5.5-2026-04-23",
7051 "qwen/qwen3-4b:q4_k_m",
7052 ] {
7053 assert!(is_builtin_model_id_form(good), "{good} should pass");
7054 }
7055 for bad in [
7056 "",
7057 "gpt-5.5",
7058 "/name",
7059 "openai/",
7060 "-openai/x",
7061 "openai/-x",
7062 "open ai/x",
7063 "openai/x y",
7064 "openai/é",
7065 "parslee/openrouter/open-fast",
7066 "openai/x:",
7067 "openai/x:-a",
7068 "openai/x:a b",
7069 "openai/x:a:b",
7070 "openai/x:a/b",
7071 ] {
7072 assert!(!is_builtin_model_id_form(bad), "{bad:?} should fail");
7073 }
7074 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7075 let off: Vec<&str> = catalog
7076 .iter()
7077 .map(|m| m.id.as_str())
7078 .filter(|id| !is_builtin_model_id_form(id))
7079 .collect();
7080 assert!(off.is_empty(), "built-in ids outside the contract: {off:?}");
7081 }
7082
7083 #[test]
7084 fn builtin_catalog_json_parses() {
7085 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON)
7086 .expect("builtin_catalog.json must be valid ModelSchema array");
7087 assert!(
7088 !catalog.is_empty(),
7089 "embedded catalog has no entries — that's almost certainly wrong"
7090 );
7091
7092 let mut seen = std::collections::HashSet::new();
7093 for entry in &catalog {
7094 assert!(
7095 seen.insert(entry.id.clone()),
7096 "duplicate id in builtin_catalog.json: {}",
7097 entry.id
7098 );
7099 }
7100 }
7101
7102 #[test]
7103 fn codex_subscription_row_has_pinnable_tool_proposal_identity() {
7104 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7105 let row = catalog
7106 .iter()
7107 .find(|model| model.id == "openai/gpt-5.6-sol:high")
7108 .expect("catalog publishes the Codex subscription identity");
7109 assert_eq!(row.name, "gpt-5.6-sol:high");
7110 assert!(matches!(
7111 &row.source,
7112 ModelSource::CodexCli { model } if model == "gpt-5.6-sol:high"
7113 ));
7114 assert!(row.has_capability(ModelCapability::Generate));
7115 assert!(row.has_capability(ModelCapability::Reasoning));
7116 assert!(row.has_capability(ModelCapability::ToolUse));
7117 assert!(row.has_capability(ModelCapability::MultiToolCall));
7118 assert!(row.has_capability(ModelCapability::Vision));
7120 assert!(!row.tags.iter().any(|tag| tag == "text-only"));
7121 assert_eq!(row.supported_params, vec![GenerateParam::MaxTokens]);
7122 assert!(!row.downloads_weights());
7123 }
7124
7125 #[test]
7126 fn codex_subscription_rows_publish_the_gpt_5_6_family() {
7127 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7128 assert_eq!(
7129 catalog
7130 .iter()
7131 .filter(|model| model.provider == "openai-codex")
7132 .count(),
7133 6,
7134 "the explicit checks below must cover every openai-codex row"
7135 );
7136 for id in [
7137 "openai-codex/gpt-5.6-sol:latest",
7138 "openai-codex/gpt-5.6-sol:high",
7139 "openai-codex/gpt-5.6-luna:latest",
7140 "openai-codex/gpt-5.6-luna:high",
7141 "openai-codex/gpt-5.6-terra:latest",
7142 "openai-codex/gpt-5.6-terra:high",
7143 ] {
7144 let row = catalog
7145 .iter()
7146 .find(|model| model.id == id)
7147 .unwrap_or_else(|| panic!("missing {id}"));
7148 assert_eq!(row.provider, "openai-codex", "{id}");
7149 assert!(
7150 matches!(&row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::ChatGptSubscription {}, .. } if provider == "openai-codex"),
7151 "{id}"
7152 );
7153 assert!(row.is_subscription_billed(), "{id}");
7154 assert!(
7155 row.cost.input_per_mtok.is_none() && row.cost.output_per_mtok.is_none(),
7156 "{id} must carry no dollar price"
7157 );
7158 assert!(row.tags.iter().any(|tag| tag == "subscription"), "{id}");
7159 assert!(
7160 row.cost
7161 .estimated_usd_bounded(Some(1_000), 1_000, 1_000, 0, 0)
7162 .is_none(),
7163 "{id} must not produce an approximate dollar cost"
7164 );
7165 let mut accidentally_priced = row.clone();
7166 accidentally_priced.cost.input_per_mtok = Some(1.0);
7167 accidentally_priced.cost.output_per_mtok = Some(1.0);
7168 assert!(
7169 crate::scoreboard_price_model(&accidentally_priced).is_none(),
7170 "{id} must stay out of the dollar scoreboard"
7171 );
7172 assert!(row.has_capability(ModelCapability::ToolUse), "{id}");
7173 assert!(row.has_capability(ModelCapability::Vision), "{id}");
7175 assert_eq!(row.name.ends_with(":high"), id.ends_with(":high"), "{id}");
7176 }
7177 let cli = catalog
7178 .iter()
7179 .find(|model| model.id == "openai/gpt-5.6-sol:high")
7180 .unwrap();
7181 assert!(
7182 matches!(&cli.source, ModelSource::CodexCli { .. }),
7183 "the Codex CLI row stays as it was"
7184 );
7185 assert!(!cli.is_subscription_billed());
7186 let api = catalog
7187 .iter()
7188 .find(|model| model.id == "openai/gpt-5.6-sol:latest")
7189 .unwrap();
7190 assert!(
7191 matches!(&api.source, ModelSource::RemoteApi { .. }),
7192 "the API-key row stays as it was"
7193 );
7194 assert!(!api.is_subscription_billed());
7195 let parslee = catalog
7196 .iter()
7197 .find(|model| model.id == "parslee/fast")
7198 .expect("catalog publishes a Parslee row");
7199 assert!(!parslee.is_subscription_billed());
7200 }
7201
7202 #[test]
7208 fn a_codex_lookup_error_clears_the_shared_hint_and_is_recorded_as_unknown() {
7209 let _environment = crate::openrouter::test_environment_scope();
7210 let tmp = TempDir::new().unwrap();
7211 let mut registry = UnifiedRegistry::new_with_state_root(
7212 tmp.path().to_path_buf(),
7213 tmp.path().join("models"),
7214 );
7215 let codex_id = "openai-codex/gpt-5.6-sol:latest";
7216
7217 remember_codex_sign_in(true);
7218 registry.record_codex_lookup(Err("the secret store is locked".into()));
7219
7220 assert!(
7221 !passive_codex_oauth_available(),
7222 "a lookup error clears the shared hint, as on main"
7223 );
7224 registry.refresh_availability();
7225 let row = registry.get(codex_id).expect("builtin Codex row").clone();
7226 assert!(
7227 !row.available,
7228 "callers reading only `available` see it unavailable"
7229 );
7230 registry.record_codex_lookup(Err("the secret store is locked".into()));
7231 assert!(
7232 registry.row_credential_state_unknown(&row),
7233 "the planner's unknown set holds the row"
7234 );
7235
7236 registry.record_codex_lookup(Ok(true));
7237 assert!(passive_codex_oauth_available());
7238 remember_codex_sign_in(false);
7239 }
7240
7241 #[test]
7242 fn codex_rows_unavailable_without_sign_in_carry_the_sign_in_reason() {
7243 let _environment = crate::openrouter::test_environment_scope();
7244 remember_codex_sign_in(false);
7245
7246 let tmp = TempDir::new().unwrap();
7247 let mut registry = UnifiedRegistry::new_with_state_root(
7248 tmp.path().to_path_buf(),
7249 tmp.path().join("models"),
7250 );
7251 let expected_ids = [
7252 "openai-codex/gpt-5.6-sol:latest",
7253 "openai-codex/gpt-5.6-sol:high",
7254 "openai-codex/gpt-5.6-luna:latest",
7255 "openai-codex/gpt-5.6-luna:high",
7256 "openai-codex/gpt-5.6-terra:latest",
7257 "openai-codex/gpt-5.6-terra:high",
7258 ];
7259
7260 for id in expected_ids {
7261 let info = ModelInfo::from(
7262 registry
7263 .get(id)
7264 .unwrap_or_else(|| panic!("missing builtin Codex row {id}")),
7265 );
7266 assert!(!info.available, "{id} must be unavailable while signed out");
7267 let reason = model_unavailable_reason(&info).expect("signed-out Codex reason");
7268 assert_eq!(reason, crate::schema::OPENAI_CODEX_SIGN_IN_HINT);
7269 assert_eq!(
7270 serde_json::to_value(&info).unwrap()["unavailable_reason"],
7271 reason
7272 );
7273 assert!(reason.contains("car auth login --provider openai-codex"));
7274 assert!(reason.contains("Settings → AI accounts"));
7275 }
7276
7277 remember_codex_sign_in(true);
7278 registry.refresh_availability();
7279
7280 for id in expected_ids {
7281 let info = ModelInfo::from(registry.get(id).unwrap());
7282 assert!(info.available, "{id} must be available while signed in");
7283 assert_eq!(model_unavailable_reason(&info), None, "{id}");
7284 assert!(serde_json::to_value(&info).unwrap()["unavailable_reason"].is_null());
7285 }
7286
7287 remember_codex_sign_in(false);
7288 }
7289
7290 #[test]
7291 fn work_context_availability_is_independent_of_other_org_rejection() {
7292 let _environment = crate::openrouter::test_environment_scope();
7293 crate::parslee_credential::clear_credential_rejected();
7294 let tmp = TempDir::new().unwrap();
7295 let mut parslee = UnifiedRegistry::new_with_state_root(
7296 tmp.path().to_path_buf(),
7297 tmp.path().join("models"),
7298 );
7299 let mut flyexclusive = parslee.clone();
7300 crate::parslee_credential::note_credential_rejected();
7301 parslee.refresh_work_context_availability("https://authority.example", true);
7302 flyexclusive.refresh_work_context_availability("https://authority.example", false);
7303 let scoped_rows: Vec<_> = parslee.all().filter(|row| matches!(
7304 &row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::OAuth2Pkce { .. }, .. }
7305 if provider == "parslee"
7306 )).collect();
7307 assert!(!scoped_rows.is_empty());
7308 for row in scoped_rows {
7309 assert!(row.available);
7310 assert!(!flyexclusive.get(&row.id).unwrap().available);
7311 }
7312 assert!(
7313 crate::parslee_credential::credential_rejected(),
7314 "scoped routing must not clear another context's observation"
7315 );
7316 crate::parslee_credential::clear_credential_rejected();
7317 }
7318
7319 #[test]
7320 fn codex_availability_leaves_openai_and_parslee_rows_alone() {
7321 let _environment = crate::openrouter::test_environment_scope();
7322 remember_codex_sign_in(false);
7323
7324 let tmp = TempDir::new().unwrap();
7325 let mut registry = UnifiedRegistry::new_with_state_root(
7326 tmp.path().to_path_buf(),
7327 tmp.path().join("models"),
7328 );
7329 registry.refresh_availability();
7330
7331 let unaffected_ids: Vec<String> = registry
7332 .list()
7333 .into_iter()
7334 .filter(|row| {
7335 matches!(
7336 row.id.as_str(),
7337 "openai/gpt-5.6-sol:latest" | "openai/gpt-5.6-sol:high"
7338 ) || row.id.starts_with("parslee/")
7339 })
7340 .map(|row| row.id.clone())
7341 .collect();
7342 assert!(unaffected_ids
7343 .iter()
7344 .any(|id| id == "openai/gpt-5.6-sol:latest"));
7345 assert!(unaffected_ids
7346 .iter()
7347 .any(|id| id == "openai/gpt-5.6-sol:high"));
7348 assert!(
7349 unaffected_ids.iter().any(|id| id.starts_with("parslee/")),
7350 "builtin catalog must contain Parslee rows"
7351 );
7352 let signed_out: std::collections::HashMap<String, bool> = unaffected_ids
7353 .iter()
7354 .map(|id| (id.clone(), registry.get(id).unwrap().available))
7355 .collect();
7356
7357 remember_codex_sign_in(true);
7358 registry.refresh_availability();
7359
7360 for id in unaffected_ids {
7361 assert_eq!(
7362 registry.get(&id).unwrap().available,
7363 signed_out[&id],
7364 "Codex sign-in must not change {id} availability"
7365 );
7366 }
7367
7368 remember_codex_sign_in(false);
7369 }
7370
7371 #[test]
7372 fn builtin_catalog_names_are_unique_for_codex_rows() {
7373 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7374 for row in catalog.iter().filter(|row| row.provider == "openai-codex") {
7375 let occurrences = catalog
7376 .iter()
7377 .filter(|candidate| candidate.name == row.name)
7378 .count();
7379 assert_eq!(
7380 occurrences, 1,
7381 "openai-codex name must be unique: {}",
7382 row.name
7383 );
7384 }
7385 }
7386
7387 #[test]
7410 fn apple_foundation_row_claims_single_tool_call_and_the_real_context_window() {
7411 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7412 let row = catalog
7413 .iter()
7414 .find(|model| model.id == "apple/foundation:default")
7415 .expect("catalog publishes the Apple FoundationModels row");
7416 assert!(matches!(
7417 &row.source,
7418 ModelSource::AppleFoundationModels { .. }
7419 ));
7420 assert!(row.has_capability(ModelCapability::Generate));
7421 assert!(
7422 row.has_capability(ModelCapability::ToolUse),
7423 "generate_with_tools exists, so the router must be able to pick this row for tool routes"
7424 );
7425 assert!(
7426 !row.has_capability(ModelCapability::MultiToolCall),
7427 "the capture sentinel ends the turn on the first tool call"
7428 );
7429 assert!(!row.has_capability(ModelCapability::Vision));
7430 assert_eq!(
7431 row.context_length, 4096,
7432 "SystemLanguageModel's context window is 4096 tokens"
7433 );
7434 assert!(!row.downloads_weights());
7435 }
7436
7437 #[test]
7450 fn apple_foundation_multi_tool_call_tracks_what_the_host_can_actually_do() {
7451 #[cfg(any(
7452 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7453 all(target_os = "ios", target_arch = "aarch64")
7454 ))]
7455 {
7456 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7457 let declared = catalog
7458 .iter()
7459 .find(|m| m.id == "apple/foundation:default")
7460 .expect("catalog publishes the Apple FoundationModels row");
7461 assert!(
7462 !declared.has_capability(ModelCapability::MultiToolCall),
7463 "the catalog literal must stay conservative; the host decides"
7464 );
7465
7466 let models_dir = tempfile::tempdir().unwrap();
7468 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
7469 let live = registry
7475 .models
7476 .get("apple/foundation:default")
7477 .expect("the builtin catalog row must survive into the live registry");
7478 let host_can = crate::backend::foundation_models::supports_parallel_tool_calls();
7479 assert_eq!(
7480 live.capabilities.contains(&ModelCapability::MultiToolCall),
7481 host_can,
7482 "live row must claim multi_tool_call iff the host supports it"
7483 );
7484 }
7485 }
7486
7487 #[test]
7499 fn apple_foundation_claims_vision_only_where_images_can_be_served() {
7500 #[cfg(any(
7501 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7502 all(target_os = "ios", target_arch = "aarch64")
7503 ))]
7504 {
7505 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7506 let declared = catalog
7507 .iter()
7508 .find(|m| m.id == "apple/foundation:default")
7509 .expect("catalog publishes the Apple FoundationModels row");
7510 assert!(
7511 !declared.has_capability(ModelCapability::Vision),
7512 "the catalog literal must stay conservative; the device decides"
7513 );
7514
7515 let models_dir = tempfile::tempdir().unwrap();
7516 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
7517 let live = registry
7518 .models
7519 .get("apple/foundation:default")
7520 .expect("the builtin catalog row must survive into the live registry");
7521
7522 assert_eq!(
7523 live.capabilities.contains(&ModelCapability::Vision),
7524 crate::backend::foundation_models::supports_vision(),
7525 "the live row must claim vision iff this device's model accepts images"
7526 );
7527 }
7528 }
7529
7530 #[test]
7547 fn a_host_without_parallel_support_gets_exactly_the_catalog_capabilities() {
7548 #[cfg(any(
7549 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7550 all(target_os = "ios", target_arch = "aarch64")
7551 ))]
7552 {
7553 if crate::backend::foundation_models::supports_parallel_tool_calls() {
7554 return;
7555 }
7556 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7557 let declared = catalog
7558 .iter()
7559 .find(|m| m.id == "apple/foundation:default")
7560 .expect("catalog publishes the Apple FoundationModels row");
7561
7562 let models_dir = tempfile::tempdir().unwrap();
7563 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
7564 let live = registry
7565 .models
7566 .get("apple/foundation:default")
7567 .expect("the builtin catalog row must survive into the live registry");
7568
7569 assert_eq!(
7570 live.capabilities, declared.capabilities,
7571 "a host without the macOS 27 behaviour must advertise exactly what \
7572 the catalog declares — nothing added, nothing removed"
7573 );
7574 }
7575 }
7576
7577 #[test]
7587 fn apple_foundation_context_size_is_either_the_os_answer_or_none() {
7588 #[cfg(any(
7589 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7590 all(target_os = "ios", target_arch = "aarch64")
7591 ))]
7592 {
7593 if let Some(window) = crate::backend::foundation_models::context_size() {
7594 assert!(
7595 window >= 512,
7596 "a context window the framework reports must be usable, got {window}"
7597 );
7598 }
7599 }
7600
7601 #[cfg(not(any(
7604 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
7605 all(target_os = "ios", target_arch = "aarch64")
7606 )))]
7607 {
7608 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7609 let row = catalog
7610 .iter()
7611 .find(|model| model.id == "apple/foundation:default")
7612 .expect("catalog publishes the Apple FoundationModels row");
7613 assert_eq!(row.context_length, 4096);
7614 }
7615 }
7616
7617 #[test]
7624 fn in_process_qwen3_models_declare_tool_use() {
7625 use crate::schema::ModelSource;
7626 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
7627 let tool_sizes = ["qwen3-4b", "qwen3-8b", "qwen3-30b-a3b"];
7630 let mut checked = 0;
7631 for entry in &catalog {
7632 let in_process = matches!(
7633 entry.source,
7634 ModelSource::Mlx { .. } | ModelSource::Local { .. }
7635 );
7636 if !in_process || !tool_sizes.iter().any(|s| entry.id.contains(s)) {
7637 continue;
7638 }
7639 assert!(
7640 entry.capabilities.contains(&ModelCapability::ToolUse),
7641 "in-process Qwen3 model {} should advertise ToolUse — the local \
7642 generate path renders/parses tool calls",
7643 entry.id
7644 );
7645 checked += 1;
7646 }
7647 assert_eq!(
7648 checked, 6,
7649 "expected 6 in-process tool-capable Qwen3 entries (3 mlx + 3 gguf)"
7650 );
7651 }
7652
7653 #[test]
7654 fn public_benchmarks_round_trip_through_model_info() {
7655 use crate::schema::BenchmarkScore;
7656 let mut reg = test_registry();
7657 let mut schema = reg
7658 .find_by_name("Qwen3-4B")
7659 .expect("catalog has Qwen3-4B")
7660 .clone();
7661 schema.id = "test/qwen3-4b-with-bench".into();
7662 schema.public_benchmarks = vec![
7663 BenchmarkScore {
7664 name: "MMLU-Pro".into(),
7665 score: 0.482,
7666 harness: Some("5-shot CoT".into()),
7667 source_url: Some("https://example.invalid/qwen3-4b-card".into()),
7668 measured_at: Some("2025-08-12".into()),
7669 runs: None,
7670 spread: None,
7671 },
7672 BenchmarkScore {
7673 name: "HumanEval".into(),
7674 score: 0.713,
7675 harness: Some("pass@1".into()),
7676 source_url: None,
7677 measured_at: None,
7678 runs: None,
7679 spread: None,
7680 },
7681 ];
7682 reg.register(schema);
7683
7684 let stored = reg
7685 .get("test/qwen3-4b-with-bench")
7686 .expect("registered model is retrievable");
7687 let info = ModelInfo::from(stored);
7688 assert_eq!(info.public_benchmarks.len(), 2);
7689
7690 let json = serde_json::to_string(&info).unwrap();
7692 assert!(json.contains("\"public_benchmarks\""));
7693 assert!(json.contains("\"MMLU-Pro\""));
7694 assert!(json.contains("\"5-shot CoT\""));
7695
7696 let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
7698 assert_eq!(decoded.public_benchmarks.len(), 2);
7699 assert_eq!(decoded.public_benchmarks[0].name, "MMLU-Pro");
7700 assert_eq!(decoded.public_benchmarks[1].name, "HumanEval");
7701 }
7702
7703 #[test]
7704 fn public_benchmarks_default_to_empty_when_absent_in_json() {
7705 let legacy_json = r#"{
7708 "id": "legacy/test:1",
7709 "name": "Legacy Test",
7710 "provider": "test",
7711 "family": "test",
7712 "version": "",
7713 "capabilities": ["generate"],
7714 "context_length": 4096,
7715 "param_count": "1B",
7716 "quantization": null,
7717 "performance": {},
7718 "cost": {},
7719 "source": { "type": "ollama", "model_tag": "legacy:1" },
7720 "tags": [],
7721 "supported_params": []
7722 }"#;
7723 let schema: ModelSchema = serde_json::from_str(legacy_json).unwrap();
7724 assert!(schema.public_benchmarks.is_empty());
7725 }
7726
7727 #[test]
7728 fn find_by_name() {
7729 let reg = test_registry();
7730 let m = reg.find_by_name("Qwen3-4B").unwrap();
7731 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7732 assert_eq!(m.id, "mlx/qwen3-4b:4bit");
7733 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7734 assert_eq!(m.id, "qwen/qwen3-4b:q4_k_m");
7735 assert!(m.has_capability(ModelCapability::Code));
7736 }
7737
7738 #[test]
7742 fn an_untagged_id_names_its_latest_row() {
7743 let reg = test_registry();
7744 let resolved = |model: &str| {
7745 reg.get(model)
7746 .or_else(|| reg.find_by_name(model))
7747 .map(|m| m.id.clone())
7748 };
7749 assert_eq!(
7750 resolved("anthropic/claude-haiku-4-5").as_deref(),
7751 Some("anthropic/claude-haiku-4-5:latest")
7752 );
7753 assert_eq!(
7756 resolved("anthropic/claude-opus-4-8").as_deref(),
7757 Some("anthropic/claude-opus-4-8")
7758 );
7759 assert_eq!(resolved("anthropic/claude-haiku-4-5:high"), None);
7760 assert_eq!(resolved("anthropic/claude-haiku-9"), None);
7761 assert_eq!(
7764 reg.find_by_name("anthropic/claude-opus-4-8")
7765 .map(|m| m.id.as_str()),
7766 None
7767 );
7768
7769 let rows = builtin_catalog();
7771 let row = |model: &str| find_model_row(&rows, model).map(|m| m.id.clone());
7772 assert_eq!(
7773 row("anthropic/claude-haiku-4-5").as_deref(),
7774 Some("anthropic/claude-haiku-4-5:latest")
7775 );
7776 assert_eq!(
7777 row("anthropic/claude-opus-4-8").as_deref(),
7778 Some("anthropic/claude-opus-4-8")
7779 );
7780 assert_eq!(
7781 row("claude-haiku-4-5").as_deref(),
7782 Some("anthropic/claude-haiku-4-5:latest")
7783 );
7784 assert_eq!(row("anthropic/claude-haiku-4-5:high"), None);
7785 assert_eq!(row("anthropic/claude-haiku-9"), None);
7786 }
7787
7788 #[test]
7789 fn query_by_capability() {
7790 let reg = test_registry();
7791 let embed_models = reg.query_by_capability(ModelCapability::Embed);
7792 assert_eq!(embed_models.len(), 2);
7793 assert!(embed_models
7794 .iter()
7795 .any(|model| model.name == "Qwen3-Embedding-0.6B"));
7796 assert!(embed_models
7797 .iter()
7798 .any(|model| model.name == "Qwen3-Embedding-0.6B-MLX"));
7799 }
7800
7801 #[test]
7802 fn query_with_filter() {
7803 let reg = test_registry();
7804 let code_small = reg.query(&ModelFilter {
7805 capabilities: vec![ModelCapability::Code],
7806 max_size_mb: Some(3000),
7807 local_only: true,
7808 ..Default::default()
7809 });
7810 assert_eq!(code_small.len(), 7);
7813 }
7814
7815 #[test]
7816 fn register_remote() {
7817 let mut reg = test_registry();
7818 let initial_len = reg.list().len();
7819 let initial_reasoning_len = reg
7820 .query(&ModelFilter {
7821 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
7822 ..Default::default()
7823 })
7824 .len();
7825 let remote = ModelSchema {
7826 id: "anthropic/claude-sonnet-4-6:latest".into(),
7827 name: "Claude Sonnet 4.6".into(),
7828 provider: "anthropic".into(),
7829 family: "claude-4".into(),
7830 version: "latest".into(),
7831 capabilities: vec![
7832 ModelCapability::Generate,
7833 ModelCapability::Code,
7834 ModelCapability::Reasoning,
7835 ModelCapability::ToolUse,
7836 ],
7837 context_length: 200000,
7838 max_output_tokens: None,
7839 param_count: String::new(),
7840 quantization: None,
7841 performance: PerformanceEnvelope {
7842 latency_p50_ms: Some(2000),
7843 ..Default::default()
7844 },
7845 cost: CostModel {
7846 input_per_mtok: Some(3.0),
7847 output_per_mtok: Some(15.0),
7848 ..Default::default()
7849 },
7850 source: ModelSource::RemoteApi {
7851 endpoint: "https://api.anthropic.com/v1/messages".into(),
7852 api_key_env: "ANTHROPIC_API_KEY".into(),
7853 api_key_envs: vec![],
7854 api_version: Some("2023-06-01".into()),
7855 protocol: ApiProtocol::Anthropic,
7856 },
7857 tags: vec![],
7858 supported_params: vec![],
7859 public_benchmarks: vec![],
7860 trust_tier: crate::schema::TrustTier::Curated,
7861 deprecated: false,
7862 available: false,
7863 weights_ready: false,
7864 };
7865
7866 reg.register(remote);
7867 assert_eq!(reg.list().len(), initial_len);
7869
7870 let reasoning = reg.query(&ModelFilter {
7871 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
7872 ..Default::default()
7873 });
7874 assert_eq!(reasoning.len(), initial_reasoning_len);
7876 }
7877
7878 #[test]
7879 fn unregister() {
7880 let mut reg = test_registry();
7881 let initial_len = reg.list().len();
7882 let removed = reg.unregister("qwen/qwen3-0.6b:q8_0");
7883 assert!(removed.is_some());
7884 assert_eq!(reg.list().len(), initial_len - 1);
7885 }
7886
7887 #[test]
7888 fn speech_models_are_curated() {
7889 let reg = test_registry();
7890 let stt = reg.query_by_capability(ModelCapability::SpeechToText);
7891 let tts = reg.query_by_capability(ModelCapability::TextToSpeech);
7892 assert_eq!(stt.len(), 3);
7894 assert_eq!(tts.len(), 5);
7896 let whisper = stt
7899 .iter()
7900 .find(|m| m.name == "Whisper-large-v3-turbo-q5_0")
7901 .expect("whisper STT model should be curated");
7902 assert!(whisper.is_local());
7903 assert!(matches!(
7904 whisper.source,
7905 crate::schema::ModelSource::WhisperCpp { .. }
7906 ));
7907 }
7908
7909 #[test]
7910 fn qwen_8b_variants_keep_tool_use_consistent() {
7911 let reg = test_registry();
7916 for name in ["Qwen3-8B", "Qwen3-8B-MLX"] {
7917 let model = reg.find_by_name(name).expect("model should exist");
7918 assert!(model.has_capability(ModelCapability::ToolUse));
7919 assert!(model.has_capability(ModelCapability::MultiToolCall));
7920 }
7921 }
7922
7923 #[test]
7924 fn mac_name_resolution_prefers_mlx_siblings() {
7925 #[allow(unused_variables)]
7928 let reg = test_registry();
7929 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7930 {
7931 assert_eq!(
7932 reg.find_by_name("Qwen3-0.6B").unwrap().id,
7933 "mlx/qwen3-0.6b:6bit"
7934 );
7935 assert_eq!(
7936 reg.find_by_name("Qwen3-1.7B").unwrap().id,
7937 "mlx/qwen3-1.7b:3bit"
7938 );
7939 assert_eq!(
7940 reg.find_by_name("Qwen3-Embedding-0.6B").unwrap().id,
7941 "mlx/qwen3-embedding-0.6b:mxfp8"
7942 );
7943 }
7944 }
7945
7946 #[test]
7947 fn remote_multimodal_models_are_curated_as_vision_capable() {
7948 let reg = test_registry();
7949 for name in [
7950 "claude-opus-4-7",
7951 "claude-opus-4-6",
7952 "claude-sonnet-4-6",
7953 "claude-haiku-4-5",
7954 "gpt-5.4",
7955 "gpt-5.4-mini",
7956 "o3",
7957 "o4-mini",
7958 "gpt-4.1-mini",
7959 "gemini-2.5-pro",
7960 "gemini-2.5-flash",
7961 ] {
7962 let model = reg.find_by_name(name).expect("model should exist");
7963 assert!(
7964 model.has_capability(ModelCapability::Vision),
7965 "{name} should be curated as vision-capable"
7966 );
7967 }
7968 }
7969
7970 #[test]
7971 fn qwen25vl_entries_are_replaced_by_qwen3vl_in_builtin_catalog() {
7972 let reg = test_registry();
7973
7974 let stale_ids = [
7975 "mlx/qwen2.5-vl-3b:4bit",
7977 "mlx/qwen2.5-vl-7b:4bit",
7978 "mlx-vlm/qwen2.5-vl-3b:4bit",
7981 "mlx-vlm/qwen2.5-vl-7b:4bit",
7982 "vllm-mlx/qwen2.5-vl-3b:4bit",
7984 ];
7985 for id in stale_ids {
7986 assert!(
7987 reg.get(id).is_none(),
7988 "{id} is superseded by Qwen3-VL; the catalog must not advertise it"
7989 );
7990 }
7991
7992 let vision_ids: Vec<&str> = reg
7993 .query_by_capability(ModelCapability::Vision)
7994 .into_iter()
7995 .map(|model| model.id.as_str())
7996 .collect();
7997 for stale in stale_ids {
7998 assert!(
7999 !vision_ids.contains(&stale),
8000 "{stale} must not be reachable through the Vision capability index"
8001 );
8002 }
8003 assert!(
8004 vision_ids.contains(&"mlx-vlm/qwen3-vl-2b:bf16"),
8005 "Qwen3-VL is the supported local VL family and must route as Vision"
8006 );
8007 }
8008
8009 #[test]
8010 fn gemini_models_are_curated_for_multimodal_tool_use() {
8011 let reg = test_registry();
8012 for name in ["gemini-2.5-pro", "gemini-2.5-flash"] {
8013 let model = reg.find_by_name(name).expect("model should exist");
8014 assert!(model.has_capability(ModelCapability::Vision));
8015 assert!(model.has_capability(ModelCapability::ToolUse));
8016 assert!(model.has_capability(ModelCapability::MultiToolCall));
8017 }
8018 }
8019
8020 #[test]
8021 fn model_info_publishes_declared_prices_and_keeps_unpriced_distinct_from_free() {
8022 let reg = test_registry();
8023
8024 let opus = reg
8026 .list()
8027 .into_iter()
8028 .find(|m| m.id == "openrouter/anthropic/claude-opus-4.8")
8029 .map(ModelInfo::from)
8030 .expect("curated opus-4.8 row is present on first boot");
8031 assert_eq!(opus.cost.input_per_mtok, Some(5.0));
8032 assert_eq!(opus.cost.output_per_mtok, Some(25.0));
8033 assert_eq!(opus.cost.cache_read_input_per_mtok, Some(0.5));
8034 assert_eq!(opus.cost.cache_write_input_per_mtok, Some(6.25));
8035
8036 let gpt = reg
8038 .list()
8039 .into_iter()
8040 .find(|m| m.id == "openrouter/openai/gpt-5.4")
8041 .map(ModelInfo::from)
8042 .expect("curated gpt-5.4 row");
8043 assert_eq!(gpt.cost.pricing_tiers.len(), 1);
8044 assert_eq!(gpt.cost.prices_for(272_000).input_per_mtok, Some(5.0));
8045
8046 let local = reg
8049 .list()
8050 .into_iter()
8051 .find(|m| m.is_local() && m.cost.input_per_mtok.is_none())
8052 .map(ModelInfo::from)
8053 .expect("the built-in catalog ships unpriced local models");
8054 let json = serde_json::to_value(&local).unwrap();
8055 assert!(json["cost"]["input_per_mtok"].is_null());
8056 assert!(json["cost"]["output_per_mtok"].is_null());
8057 assert_ne!(json["cost"]["input_per_mtok"], serde_json::json!(0.0));
8058 }
8059
8060 #[test]
8061 fn a_hand_registered_copy_of_a_curated_id_does_not_double_the_row() {
8062 let mut reg = test_registry();
8063 let id = "openrouter/anthropic/claude-opus-4.8";
8064 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
8065
8066 let mut copy = reg
8069 .list()
8070 .into_iter()
8071 .find(|m| m.id == id)
8072 .cloned()
8073 .expect("curated row");
8074 copy.name = "hand-registered".into();
8075 reg.register_user_model(copy);
8076 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
8077 }
8078
8079 #[test]
8087 fn managed_alias_publishes_prices_without_disclosing_the_upstream_id_in_the_catalog_view() {
8088 let reg = test_registry();
8089 let alias = reg
8090 .list()
8091 .into_iter()
8092 .find(|m| m.id == "parslee/openrouter/frontier-deep-next")
8093 .map(ModelInfo::from)
8094 .expect("managed alias for the new curated row");
8095
8096 assert_eq!(alias.cost.input_per_mtok, Some(5.0));
8097 assert_eq!(alias.cost.output_per_mtok, Some(25.0));
8098 assert_eq!(alias.cost.cache_read_input_per_mtok, Some(0.5));
8099 assert_eq!(alias.cost.cache_write_input_per_mtok, Some(6.25));
8100
8101 let wire = serde_json::to_string(&alias).unwrap();
8102 assert!(!wire.contains("claude-opus-4.8"));
8103 assert!(!wire.contains("anthropic/"));
8104 }
8105
8106 #[test]
8107 fn model_info_from_an_older_daemon_without_cost_still_parses() {
8108 let legacy = serde_json::json!({
8111 "id": "legacy/model",
8112 "name": "legacy",
8113 "provider": "legacy",
8114 "capabilities": ["generate"],
8115 "param_count": "",
8116 "size_mb": 0,
8117 "context_length": 8192,
8118 "available": true,
8119 "is_local": false
8120 });
8121 let info: ModelInfo = serde_json::from_value(legacy).expect("older catalog row parses");
8122 assert!(info.cost.input_per_mtok.is_none());
8123 assert!(info.cost.output_per_mtok.is_none());
8124 assert!(info.cost.pricing_tiers.is_empty());
8125 assert!(info.max_output_tokens.is_none());
8126 assert!(info.car_enabled, "legacy rows default to enabled");
8127 assert!(!info.can_remove);
8128 assert!(!info.in_use);
8129 assert!(info.management_evidence.is_none());
8130 }
8131
8132 #[test]
8133 fn visual_generation_models_are_curated() {
8134 let reg = test_registry();
8135 assert_eq!(
8136 reg.query_by_capability(ModelCapability::ImageGeneration)
8137 .len(),
8138 1
8139 );
8140 assert_eq!(
8141 reg.query_by_capability(ModelCapability::VideoGeneration)
8142 .len(),
8143 1
8144 );
8145 }
8146}
8147
8148#[cfg(test)]
8157mod builtin_catalog_validation {
8158 use super::*;
8159 use crate::schema::ModelSource;
8160
8161 fn weight_repo(source: &ModelSource) -> Option<&str> {
8163 match source {
8164 ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
8165 ModelSource::Local { hf_repo, .. } => Some(hf_repo),
8166 ModelSource::ManagedVllmMlx { hf_repo, .. } => Some(hf_repo),
8167 _ => None,
8168 }
8169 }
8170
8171 #[test]
8172 fn ids_are_unique() {
8173 let catalog = builtin_catalog();
8174 let mut seen: Vec<&str> = Vec::new();
8175 for model in &catalog {
8176 assert!(
8177 !seen.contains(&model.id.as_str()),
8178 "duplicate catalog id `{}` — the later entry silently shadows the earlier",
8179 model.id
8180 );
8181 seen.push(&model.id);
8182 }
8183 }
8184
8185 #[test]
8186 fn source_kind_is_the_serialized_source_tag() {
8187 for model in builtin_catalog() {
8188 let tagged = serde_json::to_value(&model.source).unwrap();
8189 assert_eq!(
8190 tagged["type"].as_str(),
8191 Some(model.source.kind()),
8192 "{}",
8193 model.id
8194 );
8195 assert_eq!(
8196 crate::ModelInfo::from(&model).source_kind,
8197 model.source.kind()
8198 );
8199 }
8200 let codex = builtin_catalog()
8201 .into_iter()
8202 .find(|model| model.id == "openai/gpt-5.6-sol:high")
8203 .unwrap();
8204 let api = builtin_catalog()
8205 .into_iter()
8206 .find(|model| model.id == "openai/gpt-5.6-sol:latest")
8207 .unwrap();
8208 assert_eq!(crate::ModelInfo::from(&codex).source_kind, "codex_cli");
8209 assert_eq!(crate::ModelInfo::from(&api).source_kind, "remote_api");
8210 }
8211
8212 #[test]
8213 fn exact_frontier_rows_lock_native_selectors_and_digests() {
8214 let catalog = builtin_catalog();
8215 for (id, name, version, expected_digest) in [
8216 (
8217 "openai/gpt-5.5-2026-04-23",
8218 "gpt-5.5-2026-04-23",
8219 "2026-04-23",
8220 "5b2daeae9d9115bab030e4a0630402340198ef56aaf839d6d04f22b9a1bd8eb6",
8221 ),
8222 (
8223 "anthropic/claude-opus-4-8",
8224 "claude-opus-4-8",
8225 "4.8",
8226 "f430356ae863eae3e6509443f94099d6588dba838f0a2214e574d731c8f6ed82",
8227 ),
8228 (
8229 "openai/gpt-5.6-sol:high",
8230 "gpt-5.6-sol:high",
8231 "latest",
8232 "529c449ccc425384a3af7068fca9b9a42d040f0c34cb1275dcd831c2f42e93f7",
8233 ),
8234 ] {
8235 let row = catalog
8236 .iter()
8237 .find(|model| model.id == id)
8238 .unwrap_or_else(|| panic!("missing exact production row {id}"));
8239 assert_eq!(row.name, name);
8240 assert_eq!(row.version, version);
8241 assert_eq!(
8242 crate::catalog_identity::row_digest(row).unwrap(),
8243 expected_digest,
8244 "CAR row digest drifted for {id}"
8245 );
8246 }
8247 }
8248
8249 #[test]
8250 fn weight_repos_are_well_formed_huggingface_ids() {
8251 for model in builtin_catalog() {
8252 let Some(repo) = weight_repo(&model.source) else {
8253 continue;
8254 };
8255 assert_eq!(
8256 repo.split('/').count(),
8257 2,
8258 "{}: `{repo}` is not an `org/name` HuggingFace id",
8259 model.id
8260 );
8261 assert!(
8262 !repo.split('/').any(str::is_empty),
8263 "{}: `{repo}` has an empty path segment",
8264 model.id
8265 );
8266 assert!(
8267 !repo.contains(char::is_whitespace),
8268 "{}: `{repo}` contains whitespace",
8269 model.id
8270 );
8271 }
8272 }
8273
8274 #[test]
8278 fn param_counts_are_parseable_or_deliberately_empty() {
8279 for model in builtin_catalog() {
8280 if weight_repo(&model.source).is_none() || model.param_count.is_empty() {
8281 continue;
8282 }
8283 assert!(
8284 model.param_count.starts_with(|c: char| c.is_ascii_digit()),
8285 "{}: param_count `{}` does not start with a number, so the quality \
8286 prior cannot read it — leave it empty rather than descriptive",
8287 model.id,
8288 model.param_count
8289 );
8290 }
8291 }
8292
8293 #[test]
8296 fn catalog_vllm_mlx_entries_use_explicit_managed_ownership() {
8297 for model in builtin_catalog() {
8298 if !model.is_vllm_mlx() {
8299 continue;
8300 }
8301 assert!(
8302 model.is_car_managed_vllm_mlx(),
8303 "{}: a CAR-supervised catalog row must opt into ManagedVllmMlx; \
8304 loopback alone cannot confer ownership",
8305 model.id
8306 );
8307 }
8308 }
8309
8310 #[test]
8311 fn generate_capable_models_declare_a_context_window() {
8312 for model in builtin_catalog() {
8313 if !model.has_capability(crate::schema::ModelCapability::Generate) {
8314 continue;
8315 }
8316 assert!(
8317 model.context_length > 0,
8318 "{}: a generate-capable model with no context_length breaks budget sizing",
8319 model.id
8320 );
8321 }
8322 }
8323
8324 #[test]
8325 fn every_entry_declares_at_least_one_capability() {
8326 for model in builtin_catalog() {
8327 assert!(
8328 !model.capabilities.is_empty(),
8329 "{}: an entry with no capabilities can never be routed to",
8330 model.id
8331 );
8332 }
8333 }
8334}
8335
8336#[cfg(test)]
8337mod gguf_quantization_tests {
8338 use crate::schema::{QuantScheme, Quantization};
8339
8340 fn quantization_from_gguf_filename(name: &str) -> Option<Quantization> {
8341 Quantization::from_gguf_filename(name)
8342 }
8343
8344 #[test]
8345 fn reads_the_quantization_a_gguf_file_names() {
8346 let cases = [
8347 ("Qwen3-8B-Q4_K_M.gguf", "Q4_K_M", QuantScheme::KQuantMixed),
8348 (
8349 "Qwen3-Embedding-0.6B-Q8_0.gguf",
8350 "Q8_0",
8351 QuantScheme::RtnBlock,
8352 ),
8353 (
8354 "ggml-large-v3-turbo-q5_0.gguf",
8355 "q5_0",
8356 QuantScheme::RtnBlock,
8357 ),
8358 ("model-IQ4_XS.gguf", "IQ4_XS", QuantScheme::KQuantMixed),
8359 ];
8360 for (filename, label, scheme) in cases {
8361 let q = quantization_from_gguf_filename(filename)
8362 .unwrap_or_else(|| panic!("no quantization found in {filename}"));
8363 assert_eq!(q.label, label, "label for {filename}");
8364 assert_eq!(q.scheme, scheme, "scheme for {filename}");
8365 }
8366 }
8367
8368 #[test]
8370 fn returns_none_when_the_name_says_nothing() {
8371 for filename in ["model.gguf", "llama-2-7b-chat.gguf", "ggml-base.gguf"] {
8372 assert!(
8373 quantization_from_gguf_filename(filename).is_none(),
8374 "should not have guessed from {filename}"
8375 );
8376 }
8377 }
8378
8379 #[test]
8382 fn the_rightmost_match_wins() {
8383 let q = quantization_from_gguf_filename("q8-experiment-Q4_K_M.gguf").unwrap();
8384 assert_eq!(q.label, "Q4_K_M");
8385 }
8386}
8387
8388#[cfg(test)]
8389mod mlx_executor_discrimination_tests {
8390 use super::*;
8391
8392 #[test]
8404 fn only_in_process_text_rows_need_the_swift_loader() {
8405 let catalog = builtin_catalog();
8406 let row = |id: &str| {
8407 catalog
8408 .iter()
8409 .find(|m| m.id == id)
8410 .unwrap_or_else(|| panic!("{id} missing from the builtin catalog"))
8411 };
8412
8413 for id in [
8414 "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",
8419 ] {
8420 let m = row(id);
8421 assert!(
8422 matches!(m.source, ModelSource::Mlx { .. }),
8423 "{id}: precondition — this test is only meaningful for Mlx-sourced rows"
8424 );
8425 assert!(
8426 !mlx_row_needs_in_process_loader(m),
8427 "{id} runs in a subprocess; its availability must not depend on the \
8428 in-process Swift loader"
8429 );
8430 }
8431
8432 for id in ["mlx/qwen3-4b:4bit", "mlx/gemma-4-12b-it:4bit"] {
8435 assert!(
8436 mlx_row_needs_in_process_loader(row(id)),
8437 "{id} is served in-process and does need the loader"
8438 );
8439 }
8440 }
8441}
8442
8443#[cfg(test)]
8444mod local_availability_tests {
8445 use super::*;
8446 use crate::schema::ModelSchema;
8447 use tempfile::TempDir;
8448
8449 fn gguf_row(id: &str, hf_repo: &str) -> ModelSchema {
8450 let mut schema: ModelSchema = serde_json::from_value(serde_json::json!({
8451 "id": id,
8452 "name": id.replace('/', "-"),
8453 "provider": "qwen",
8454 "family": "qwen3",
8455 "capabilities": ["generate"],
8456 "context_length": 32768,
8457 "param_count": "8B",
8458 "source": {
8459 "type": "local",
8460 "hf_repo": hf_repo,
8461 "hf_filename": "model.gguf",
8462 "tokenizer_repo": hf_repo,
8463 },
8464 "cost": { "size_mb": 4900 },
8465 }))
8466 .unwrap();
8467 schema.available = false;
8468 schema
8469 }
8470
8471 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
8482 #[test]
8483 fn a_declared_repo_is_available_before_it_is_downloaded() {
8484 let _environment = crate::openrouter::test_environment_scope();
8485 let tmp = TempDir::new().unwrap();
8486 let models = tmp.path().join("models");
8487 std::fs::create_dir_all(&models).unwrap();
8488
8489 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
8490 reg.register(gguf_row("qwen/test-8b:q4_k_m", "Qwen/Qwen3-8B-GGUF"));
8491
8492 let model = reg.get("qwen/test-8b:q4_k_m").expect("registered");
8493 assert!(
8494 model.available,
8495 "a GGUF model with a repo to fetch from must not report unavailable \
8496 just because nothing has downloaded it yet"
8497 );
8498 }
8499
8500 #[test]
8504 fn a_row_with_nowhere_to_fetch_from_stays_unavailable() {
8505 let _environment = crate::openrouter::test_environment_scope();
8506 let tmp = TempDir::new().unwrap();
8507 let models = tmp.path().join("models");
8508 std::fs::create_dir_all(&models).unwrap();
8509
8510 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
8511 reg.register(gguf_row("local/scanned", ""));
8512
8513 let model = reg.get("local/scanned").expect("registered");
8514 assert!(
8515 !model.available,
8516 "an empty hf_repo has no download to promise"
8517 );
8518 }
8519
8520 #[test]
8521 fn builtin_credential_names_cover_remote_and_proprietary_auth() {
8522 let names = builtin_credential_env_names();
8523 for name in [
8524 "OPENAI_API_KEY",
8525 "ANTHROPIC_API_KEY",
8526 crate::openrouter::API_KEY_ENV,
8527 ] {
8528 assert!(names.contains(name), "built-in catalog omitted {name}");
8529 }
8530 }
8531}