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 project_model_ids: HashSet<String>,
164 signed_origin: HashMap<String, std::sync::Arc<crate::catalog::CatalogCacheEnvelope>>,
168 retained_ids: HashSet<String>,
171 revoked_ids: HashSet<String>,
174 revoked_builtin_ids: HashSet<String>,
177 signed_overlay_ids: HashSet<String>,
181 signed_catalog_ids: HashSet<String>,
185 builtin_model_ids: HashSet<String>,
188 user_config_ids: HashSet<String>,
196 on_disk_discovered_ids: HashSet<String>,
203 provider_discovered_ids: HashSet<String>,
206 user_config_path: PathBuf,
209 ambient_progress: ProgressSink,
223 session: SessionProbe,
228}
229
230#[derive(Debug, Clone, Deserialize)]
231struct ModelUpgradeRule {
232 from_ids: Vec<String>,
233 to_id: String,
234 reason: String,
235 target_runtime: Option<String>,
236 target_runtime_requirement: Option<String>,
237 #[serde(default)]
238 minimum_runtimes: Vec<ModelRuntimeRequirement>,
239 #[serde(default = "default_remove_old_after_available")]
240 remove_old_after_available: bool,
241}
242
243fn default_remove_old_after_available() -> bool {
244 true
245}
246
247fn environment_credential_available(env_var: &str) -> bool {
248 std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
249}
250
251pub(crate) fn credential_present_without_reading_it(env_var: &str) -> bool {
273 matches!(credential_presence(env_var), CredentialPresence::Present)
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub(crate) enum CredentialPresence {
284 Present,
285 Absent,
286 Unknown,
288}
289
290pub(crate) fn credential_presence(env_var: &str) -> CredentialPresence {
291 if environment_credential_available(env_var) {
292 return CredentialPresence::Present;
293 }
294 match car_secrets::status_via_operator_broker_or_keychain(&car_secrets::SecretRef::new(
295 car_secrets::DEFAULT_SERVICE,
296 env_var,
297 )) {
298 Ok(status) if status.exists => CredentialPresence::Present,
299 Ok(_) => CredentialPresence::Absent,
300 Err(_) => CredentialPresence::Unknown,
301 }
302}
303
304static CODEX_SIGNED_IN_HINT: std::sync::atomic::AtomicBool =
309 std::sync::atomic::AtomicBool::new(false);
310
311pub fn remember_codex_sign_in(present: bool) {
316 CODEX_SIGNED_IN_HINT.store(present, std::sync::atomic::Ordering::Relaxed);
317}
318
319pub fn passive_codex_oauth_available() -> bool {
322 CODEX_SIGNED_IN_HINT.load(std::sync::atomic::Ordering::Relaxed)
323}
324
325fn passive_parslee_oauth_available() -> bool {
326 matches!(
327 car_auth::credential_authority_hint().state,
328 car_auth::CredentialAuthorityState::Configured
329 )
330}
331
332fn proprietary_auth_available(
338 model_id: &str,
339 schema_provider: &str,
340 source_provider: &str,
341 auth: &ProprietaryAuth,
342 parslee_oauth_available: bool,
343 resolved: &std::collections::HashMap<String, bool>,
344) -> bool {
345 if crate::openrouter::is_curated_managed_gateway_alias(model_id)
361 && crate::openrouter::gateway_unconfigured()
362 {
363 return false;
364 }
365 if crate::parslee_credential::credential_rejected()
379 && matches!(auth, ProprietaryAuth::OAuth2Pkce { .. })
380 {
381 return false;
382 }
383 match auth {
384 ProprietaryAuth::ApiKeyEnv { env_var } | ProprietaryAuth::BearerTokenEnv { env_var } => {
385 resolved.get(env_var).copied().unwrap_or(false)
386 }
387 ProprietaryAuth::OAuth2Pkce { .. } => {
388 schema_provider.eq_ignore_ascii_case("parslee")
389 && source_provider.eq_ignore_ascii_case("parslee")
390 && parslee_oauth_available
391 }
392 ProprietaryAuth::ChatGptSubscription {} => {
393 schema_provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
394 && source_provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
395 && passive_codex_oauth_available()
396 }
397 }
398}
399
400fn model_upgrade_rules() -> Vec<ModelUpgradeRule> {
401 serde_json::from_str(include_str!("../assets/model-upgrades.json"))
402 .expect("built-in model-upgrades.json should parse")
403}
404
405pub const USER_MODELS_FILE: &str = "models.json";
407
408pub fn user_config_path() -> Option<PathBuf> {
426 car_home::root().map(|root| root.join(USER_MODELS_FILE))
427}
428
429impl UnifiedRegistry {
430 pub fn new(models_dir: PathBuf) -> Self {
434 Self::new_with_state_root(car_home::root_or_relative(), models_dir)
435 }
436
437 pub fn new_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
441 let catalog_public_key = crate::catalog::catalog_public_key();
442 Self::new_with_catalog_public_key(state_root, models_dir, Some(&catalog_public_key))
443 }
444
445 fn new_with_catalog_public_key(
446 state_root: PathBuf,
447 models_dir: PathBuf,
448 catalog_public_key: Option<&str>,
449 ) -> Self {
450 #[cfg(not(test))]
458 let session = SessionProbe::Live;
459 #[cfg(test)]
460 let session = SessionProbe::Inert;
461 Self::new_with_session(state_root, models_dir, catalog_public_key, session)
462 }
463
464 pub(crate) fn new_with_session(
467 state_root: PathBuf,
468 models_dir: PathBuf,
469 catalog_public_key: Option<&str>,
470 session: SessionProbe,
471 ) -> Self {
472 let user_config_path = state_root.join(USER_MODELS_FILE);
473
474 let mut registry = Self {
475 models_dir,
476 state_root,
477 models: HashMap::new(),
478 credential_state_unknown: HashSet::new(),
479 project_model_ids: HashSet::new(),
480 signed_catalog_ids: HashSet::new(),
481 signed_overlay_ids: HashSet::new(),
482 signed_origin: HashMap::new(),
483 retained_ids: HashSet::new(),
484 revoked_ids: HashSet::new(),
485 revoked_builtin_ids: HashSet::new(),
486 builtin_model_ids: HashSet::new(),
487 user_config_ids: HashSet::new(),
488 on_disk_discovered_ids: HashSet::new(),
489 provider_discovered_ids: HashSet::new(),
490 user_config_path,
491 ambient_progress: ProgressSink::none(),
492 session,
493 };
494 registry.load_builtin_catalog();
495 let cached = crate::catalog::load_verified(
502 &crate::catalog::cache_path(®istry.state_root),
503 catalog_public_key,
504 );
505 if let Some(cached) = cached {
506 let origin = std::sync::Arc::new(cached.envelope());
507 let revoked: HashSet<String> = cached.revoked().iter().cloned().collect();
508 registry.replace_signed_catalog(
509 cached.into_models(),
510 Some(origin),
511 &HashSet::new(),
512 &revoked,
513 );
514 }
515 registry.restore_retained(crate::catalog::load_retained(
516 &crate::catalog::retained_path(®istry.state_root),
517 catalog_public_key,
518 ));
519 for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
527 ®istry.state_models_dir(),
528 )) {
529 if !registry.models.contains_key(&schema.id) && !registry.is_revoked(&schema.id) {
530 registry.provider_discovered_ids.insert(schema.id.clone());
531 registry.register(schema);
532 }
533 }
534 registry.refresh_availability();
535 let _ = registry.load_user_config();
537 registry.discover_on_disk_models();
541 registry
542 }
543
544 fn empty_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
545 let user_config_path = state_root.join(USER_MODELS_FILE);
546 Self {
547 models_dir,
548 state_root,
549 models: HashMap::new(),
550 credential_state_unknown: HashSet::new(),
551 project_model_ids: HashSet::new(),
552 signed_catalog_ids: HashSet::new(),
553 signed_overlay_ids: HashSet::new(),
554 signed_origin: HashMap::new(),
555 retained_ids: HashSet::new(),
556 revoked_ids: HashSet::new(),
557 revoked_builtin_ids: HashSet::new(),
558 builtin_model_ids: HashSet::new(),
559 user_config_ids: HashSet::new(),
560 on_disk_discovered_ids: HashSet::new(),
561 provider_discovered_ids: HashSet::new(),
562 user_config_path,
563 ambient_progress: ProgressSink::none(),
564 session: SessionProbe::Inert,
567 }
568 }
569
570 #[cfg(test)]
585 pub fn new_empty(models_dir: PathBuf) -> Self {
586 let state_root = models_dir.parent().unwrap_or(&models_dir).to_path_buf();
587 Self::empty_with_state_root(state_root, models_dir)
588 }
589
590 pub(crate) fn new_isolated_for_diagnosis(state_root: PathBuf, models_dir: PathBuf) -> Self {
597 let mut registry = Self::empty_with_state_root(state_root, models_dir);
598 registry.discover_on_disk_models();
599 registry
600 }
601
602 fn state_models_dir(&self) -> PathBuf {
607 self.state_root.join("models")
608 }
609
610 fn discover_on_disk_models(&mut self) {
624 let entries = match std::fs::read_dir(&self.models_dir) {
625 Ok(e) => e,
626 Err(_) => return,
627 };
628 let known: std::collections::HashSet<String> = self
633 .models
634 .values()
635 .map(|m| m.name.to_ascii_lowercase())
636 .chain(
637 crate::catalog::load_revoked_names(&crate::catalog::revoked_names_path(
638 &self.state_root,
639 ))
640 .into_keys()
641 .map(|name| name.to_ascii_lowercase()),
642 )
643 .collect();
644
645 for entry in entries.flatten() {
646 let path = entry.path();
647 if !path.is_dir() {
648 continue;
649 }
650 let Some(name) = path
651 .file_name()
652 .and_then(|n| n.to_str())
653 .map(str::to_string)
654 else {
655 continue;
656 };
657 if known.contains(&name.to_ascii_lowercase()) {
658 continue;
659 }
660
661 let Some(schema) = synthesize_local_schema(&name, &path) else {
662 continue;
663 };
664 if self.models.contains_key(&schema.id) {
670 tracing::debug!(
671 id = %schema.id,
672 name = %name,
673 "skipping local model directory whose id is already registered (car#1650)"
674 );
675 continue;
676 }
677 tracing::info!(
678 id = %schema.id,
679 name = %name,
680 "auto-discovered uncatalogued local model under models_dir (car-releases#62)"
681 );
682 let id = schema.id.clone();
683 self.register(schema);
684 if self.models.contains_key(&id) {
685 self.on_disk_discovered_ids.insert(id);
686 }
687 }
688 }
689
690 pub(crate) fn prune_missing_on_disk_models(&mut self) {
697 let missing = self
698 .on_disk_discovered_ids
699 .iter()
700 .filter(|id| {
701 self.models
702 .get(id.as_str())
703 .is_none_or(|schema| !self.models_dir.join(&schema.name).is_dir())
704 })
705 .cloned()
706 .collect::<Vec<_>>();
707 for id in missing {
708 tracing::debug!(
709 model_id = %id,
710 "dropping vanished auto-discovered model; coordination records do not register models"
711 );
712 self.on_disk_discovered_ids.remove(&id);
713 self.models.remove(&id);
714 }
715 }
716
717 pub fn register(&mut self, mut schema: ModelSchema) {
725 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
726 warn!(id = %schema.id, "ignoring user registration for reserved Parslee-managed alias");
727 return;
728 }
729 if self.project_model_ids.contains(&schema.id) {
730 warn!(id = %schema.id, "ignoring public registration for project-owned exact id");
731 return;
732 }
733 schema.mark_user_registered();
734 let id = schema.id.clone();
735 if self.register_preserving_trust(schema) {
736 self.on_disk_discovered_ids.remove(&id);
737 }
738 }
739
740 pub(crate) fn register_project_model(&mut self, schema: ModelSchema) -> bool {
746 let id = schema.id.clone();
747 if !self.register_preserving_trust(schema) {
748 return false;
749 }
750 self.on_disk_discovered_ids.remove(&id);
751 self.project_model_ids.insert(id);
752 true
753 }
754
755 pub(crate) fn replace_signed_catalog(
771 &mut self,
772 rows: Vec<ModelSchema>,
773 origin: Option<std::sync::Arc<crate::catalog::CatalogCacheEnvelope>>,
774 keep: &HashSet<String>,
775 revoked: &HashSet<String>,
776 ) -> usize {
777 let incoming: HashSet<&str> = rows.iter().map(|m| m.id.as_str()).collect();
778 for id in std::mem::take(&mut self.signed_catalog_ids) {
779 if keep.contains(&id) && !incoming.contains(id.as_str()) && !revoked.contains(&id) {
782 if let Some(row) = self.models.get_mut(&id) {
783 row.deprecated = true;
784 }
785 self.retained_ids.insert(id.clone());
786 self.signed_catalog_ids.insert(id);
787 continue;
788 }
789 self.models.remove(&id);
790 self.project_model_ids.remove(&id);
791 self.credential_state_unknown.remove(&id);
792 self.signed_origin.remove(&id);
793 self.retained_ids.remove(&id);
794 }
795 let overlaid = std::mem::take(&mut self.signed_overlay_ids);
796 let unrevoked = std::mem::take(&mut self.revoked_builtin_ids);
797 let restore: HashSet<String> = overlaid.union(&unrevoked).cloned().collect();
798 let compiled: HashMap<String, ModelSchema> = if restore.is_empty() {
799 HashMap::new()
800 } else {
801 builtin_catalog()
802 .into_iter()
803 .filter(|m| restore.contains(&m.id))
804 .map(|m| (m.id.clone(), m))
805 .collect()
806 };
807 for id in &restore {
808 if let (Some(row), Some(original)) = (self.models.get_mut(id), compiled.get(id)) {
809 Self::copy_overlay_fields(original, row);
810 }
811 }
812 let lifted: HashSet<String> = std::mem::replace(&mut self.revoked_ids, revoked.clone())
813 .difference(revoked)
814 .cloned()
815 .collect();
816 for id in revoked {
819 if self.provider_discovered_ids.remove(id) && !self.user_config_ids.contains(id) {
820 self.models.remove(id);
821 }
822 }
823 let mut loaded = 0;
824 for schema in rows {
825 let id = schema.id.clone();
826 if revoked.contains(&id) {
827 warn!(%id, "ignoring a signed catalog row its own catalog revokes");
828 continue;
829 }
830 if self.register_signed_catalog_model(schema) {
831 loaded += 1;
832 if let Some(origin) = &origin {
833 if self.signed_catalog_ids.contains(&id) {
834 self.signed_origin.insert(id, origin.clone());
835 }
836 }
837 }
838 }
839 if !lifted.is_empty() {
842 for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
843 &self.state_models_dir(),
844 )) {
845 if lifted.contains(&schema.id) && !self.models.contains_key(&schema.id) {
846 self.provider_discovered_ids.insert(schema.id.clone());
847 self.register(schema);
848 }
849 }
850 }
851 for id in revoked {
854 if self.builtin_model_ids.contains(id) {
855 if let Some(row) = self.models.get_mut(id) {
856 row.deprecated = true;
857 self.revoked_builtin_ids.insert(id.clone());
858 }
859 }
860 }
861 loaded
862 }
863
864 pub fn is_revoked(&self, id: &str) -> bool {
866 self.revoked_ids.contains(id)
867 }
868
869 pub fn revoked_ids(&self) -> Vec<String> {
871 self.revoked_ids.iter().cloned().collect()
872 }
873
874 pub fn is_builtin(&self, id: &str) -> bool {
877 self.builtin_model_ids.contains(id)
878 }
879
880 pub(crate) fn retained_rows(
883 &self,
884 ) -> Vec<(String, std::sync::Arc<crate::catalog::CatalogCacheEnvelope>)> {
885 let mut rows: Vec<_> = self
886 .retained_ids
887 .iter()
888 .filter_map(|id| Some((id.clone(), self.signed_origin.get(id)?.clone())))
889 .collect();
890 rows.sort_by(|a, b| a.0.cmp(&b.0));
891 rows
892 }
893
894 pub(crate) fn signed_origin_of(
896 &self,
897 id: &str,
898 ) -> Option<std::sync::Arc<crate::catalog::CatalogCacheEnvelope>> {
899 self.signed_origin.get(id).cloned()
900 }
901
902 pub(crate) fn restore_retained(
906 &mut self,
907 rows: Vec<(
908 ModelSchema,
909 std::sync::Arc<crate::catalog::CatalogCacheEnvelope>,
910 )>,
911 ) {
912 for (mut schema, origin) in rows {
913 if self.models.contains_key(&schema.id) || self.revoked_ids.contains(&schema.id) {
914 continue;
915 }
916 schema.deprecated = true;
917 let id = schema.id.clone();
918 if self.register_signed_catalog_model(schema) && self.signed_catalog_ids.contains(&id) {
919 self.signed_origin.insert(id.clone(), origin);
920 self.retained_ids.insert(id);
921 }
922 }
923 }
924
925 fn copy_overlay_fields(from: &ModelSchema, to: &mut ModelSchema) {
928 to.public_benchmarks = from.public_benchmarks.clone();
929 to.cost = from.cost.clone();
930 to.performance = from.performance.clone();
931 to.deprecated = from.deprecated;
932 }
933
934 fn overlay_builtin(&mut self, schema: &ModelSchema) -> bool {
938 let Some(row) = self.models.get_mut(&schema.id) else {
939 return false;
940 };
941 let same_weights =
942 serde_json::to_value(&row.source).ok() == serde_json::to_value(&schema.source).ok();
943 if !same_weights {
944 warn!(id = %schema.id, "ignoring signed overlay that names different weights for a compiled builtin");
945 return false;
946 }
947 Self::copy_overlay_fields(schema, row);
948 self.signed_overlay_ids.insert(schema.id.clone());
949 true
950 }
951
952 fn register_signed_catalog_model(&mut self, schema: ModelSchema) -> bool {
956 if self.builtin_model_ids.contains(&schema.id) {
957 if self.signed_overlay_ids.contains(&schema.id) {
958 warn!(id = %schema.id, "ignoring duplicate signed overlay for compiled builtin");
959 return false;
960 }
961 return self.overlay_builtin(&schema);
962 }
963 if self.user_config_ids.contains(&schema.id) {
964 warn!(id = %schema.id, "ignoring signed catalog row for a user-registered id");
965 return false;
966 }
967 if self.project_model_ids.contains(&schema.id) {
968 warn!(id = %schema.id, "ignoring duplicate signed catalog row for project-owned exact id");
969 return false;
970 }
971 let id = schema.id.clone();
972 let registered = self.register_project_model(schema);
973 if registered {
974 self.signed_catalog_ids.insert(id);
975 }
976 registered
977 }
978
979 fn register_preserving_trust(&mut self, mut schema: ModelSchema) -> bool {
980 if let Err(error) = crate::catalog_identity::row_digest(&schema) {
981 warn!(id = %schema.id, %error, "rejecting model without canonical catalog identity");
982 return false;
983 }
984 if schema.is_mlx() || schema.is_car_managed_vllm_mlx() {
986 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
997 {
998 schema.available = if schema.tags.contains(&"speech".to_string()) {
999 speech_mlx_available()
1000 } else if let ModelSource::Mlx { ref hf_repo, .. }
1001 | ModelSource::ManagedVllmMlx { ref hf_repo, .. } = schema.source
1002 {
1003 let loader_ok = !mlx_row_needs_in_process_loader(&schema)
1017 || crate::backend::local::in_process_loader_available();
1018 let mlx_dir = self.models_dir.join(&schema.name);
1019 loader_ok && (mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty())
1020 } else {
1021 let mlx_dir = self.models_dir.join(&schema.name);
1022 mlx_dir_has_weights(&mlx_dir)
1023 };
1024 }
1025 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1026 {
1027 schema.available = false;
1028 }
1029 } else if schema.is_vllm_mlx() {
1030 schema.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available;
1032 } else if matches!(schema.source, ModelSource::WhisperCpp { .. }) {
1033 schema.available = true;
1038 } else if matches!(schema.source, ModelSource::WindowsSpeech {}) {
1039 schema.available = cfg!(target_os = "windows");
1041 } else if schema.is_codex_cli() {
1042 schema.available = crate::backend::codex_cli::is_available();
1046 } else if schema.is_local() {
1047 let local_path = self.models_dir.join(&schema.name).join("model.gguf");
1048 let lazily_fetchable = matches!(
1054 schema.source,
1055 ModelSource::Local { ref hf_repo, .. } if !hf_repo.is_empty()
1056 ) && !cfg!(all(
1057 target_os = "macos",
1058 target_arch = "aarch64",
1059 not(car_skip_mlx)
1060 ));
1061 schema.available = local_path.exists() || lazily_fetchable;
1062 } else if schema.is_remote() {
1063 schema.available = match schema.source {
1067 ModelSource::RemoteApi {
1068 protocol: crate::schema::ApiProtocol::OpenRouter,
1069 ..
1070 } => crate::openrouter::credential_source().is_some(),
1071 ModelSource::RemoteApi {
1072 ref api_key_env, ..
1073 } => environment_credential_available(api_key_env),
1074 ModelSource::Proprietary {
1075 ref provider,
1076 ref auth,
1077 ..
1078 } => {
1079 let resolved = match auth {
1080 ProprietaryAuth::ApiKeyEnv { env_var }
1081 | ProprietaryAuth::BearerTokenEnv { env_var } => {
1082 std::collections::HashMap::from([(
1083 env_var.clone(),
1084 environment_credential_available(env_var),
1085 )])
1086 }
1087 ProprietaryAuth::OAuth2Pkce { .. }
1088 | ProprietaryAuth::ChatGptSubscription {} => Default::default(),
1089 };
1090 proprietary_auth_available(
1091 &schema.id,
1092 &schema.provider,
1093 provider,
1094 auth,
1095 self.session.available(),
1096 &resolved,
1097 )
1098 }
1099 _ => schema.available,
1100 };
1101 }
1102 schema.weights_ready = physical_weights_ready(&schema, &self.models_dir);
1115 debug!(
1127 id = %schema.id,
1128 name = %schema.name,
1129 available = schema.available,
1130 weights_ready = schema.weights_ready,
1131 "registered model"
1132 );
1133 self.models.insert(schema.id.clone(), schema);
1134 true
1135 }
1136
1137 pub fn register_user_model(&mut self, mut schema: ModelSchema) {
1144 if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
1145 warn!(id = %schema.id, "ignoring persisted user model for reserved Parslee-managed alias");
1146 return;
1147 }
1148 if self.project_model_ids.contains(&schema.id) {
1149 warn!(id = %schema.id, "ignoring persisted user model for project-owned exact id");
1150 return;
1151 }
1152 schema.mark_user_registered();
1153 let id = schema.id.clone();
1154 if self.register_preserving_trust(schema) {
1155 self.on_disk_discovered_ids.remove(&id);
1156 self.provider_discovered_ids.remove(&id);
1157 self.user_config_ids.insert(id);
1158 }
1159 }
1160
1161 pub fn unregister(&mut self, id: &str) -> Option<ModelSchema> {
1163 self.on_disk_discovered_ids.remove(id);
1164 self.provider_discovered_ids.remove(id);
1165 let removed = self.models.remove(id);
1166 if let Some(ref m) = removed {
1167 info!(id = %m.id, "unregistered model");
1168 }
1169 removed
1170 }
1171
1172 pub fn unregister_user_model(&mut self, id: &str) -> Option<ModelSchema> {
1177 if !self.user_config_ids.remove(id) {
1178 return None;
1179 }
1180 self.unregister(id)
1181 }
1182
1183 pub fn list(&self) -> Vec<&ModelSchema> {
1185 let mut models: Vec<&ModelSchema> = self.models.values().collect();
1186 models.sort_by(|a, b| a.id.cmp(&b.id));
1187 models
1188 }
1189
1190 pub fn query(&self, filter: &ModelFilter) -> Vec<&ModelSchema> {
1192 self.models
1193 .values()
1194 .filter(|m| {
1195 if !filter.capabilities.iter().all(|c| m.has_capability(*c)) {
1197 return false;
1198 }
1199 if let Some(max) = filter.max_size_mb {
1201 if m.size_mb() > max && m.is_local() {
1202 return false;
1203 }
1204 }
1205 if let Some(max) = filter.max_latency_ms {
1207 if let Some(p50) = m.performance.latency_p50_ms {
1208 if p50 > max {
1209 return false;
1210 }
1211 }
1212 }
1213 if let Some(max) = filter.max_cost_per_mtok {
1215 if let Some(cost) = m.cost.output_per_mtok {
1216 if cost > max {
1217 return false;
1218 }
1219 }
1220 }
1221 if !filter.tags.iter().all(|t| m.tags.contains(t)) {
1223 return false;
1224 }
1225 if let Some(ref p) = filter.provider {
1227 if &m.provider != p {
1228 return false;
1229 }
1230 }
1231 if filter.local_only && !m.is_local() {
1233 return false;
1234 }
1235 if filter.available_only && !m.available_now() {
1237 return false;
1238 }
1239 true
1240 })
1241 .collect()
1242 }
1243
1244 pub fn query_by_capability(&self, cap: ModelCapability) -> Vec<&ModelSchema> {
1246 self.query(&ModelFilter {
1247 capabilities: vec![cap],
1248 ..Default::default()
1249 })
1250 }
1251
1252 pub fn available_upgrades(&self) -> Vec<ModelUpgrade> {
1254 let mut upgrades = Vec::new();
1255 for rule in model_upgrade_rules() {
1256 let Some(from) = rule
1257 .from_ids
1258 .iter()
1259 .find_map(|id| self.models.get(id.as_str()))
1260 .filter(|schema| schema.available)
1261 else {
1262 continue;
1263 };
1264 let Some(to) = self.models.get(rule.to_id.as_str()) else {
1265 continue;
1266 };
1267 upgrades.push(ModelUpgrade {
1268 from_id: from.id.clone(),
1269 from_name: from.name.clone(),
1270 to_id: to.id.clone(),
1271 to_name: to.name.clone(),
1272 reason: rule.reason.clone(),
1273 target_runtime: rule.target_runtime.clone(),
1274 target_runtime_requirement: rule.target_runtime_requirement.clone(),
1275 minimum_runtimes: rule.minimum_runtimes.clone(),
1276 target_available: to.available,
1277 target_pullable: matches!(
1278 to.source,
1279 ModelSource::Local { .. } | ModelSource::Mlx { .. }
1280 ),
1281 remove_old_supported: matches!(
1282 from.source,
1283 ModelSource::Local { .. } | ModelSource::Mlx { .. }
1284 ) && rule.remove_old_after_available,
1285 });
1286 }
1287 upgrades.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
1288 upgrades.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
1289 upgrades
1290 }
1291
1292 pub fn get(&self, id: &str) -> Option<&ModelSchema> {
1294 self.models.get(id)
1295 }
1296
1297 pub fn registered_schema(&self, id: &str) -> Option<&ModelSchema> {
1303 self.get(id)
1304 }
1305
1306 pub fn all(&self) -> impl Iterator<Item = &ModelSchema> {
1308 self.models.values()
1309 }
1310
1311 pub fn find_by_name(&self, name: &str) -> Option<&ModelSchema> {
1314 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1315 if !name.to_ascii_lowercase().ends_with("-mlx") {
1316 if let Some(mlx_variant) = self
1317 .models
1318 .values()
1319 .find(|m| m.name.eq_ignore_ascii_case(&format!("{name}-MLX")))
1320 {
1321 return Some(mlx_variant);
1322 }
1323 }
1324
1325 self.models
1326 .values()
1327 .find(|m| m.name.eq_ignore_ascii_case(name))
1328 }
1329
1330 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1334 pub fn resolve_mlx_equivalent(&self, schema: &ModelSchema) -> Option<&ModelSchema> {
1335 if schema.is_mlx() || schema.is_vllm_mlx() {
1337 return None;
1338 }
1339 if !matches!(schema.source, ModelSource::Local { .. }) {
1341 return None;
1342 }
1343 let primary_cap = schema.capabilities.first()?;
1350 self.models.values().find(|m| {
1351 m.is_mlx()
1352 && m.family == schema.family
1353 && m.param_count == schema.param_count
1354 && m.capabilities.contains(primary_cap)
1355 })
1356 }
1357
1358 pub async fn ensure_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1360 let sink = self.ambient_progress.clone();
1365 self.ensure_local_with_progress(id, &sink).await
1366 }
1367
1368 pub fn set_ambient_progress(&mut self, sink: ProgressSink) {
1375 self.ambient_progress = sink;
1376 }
1377
1378 pub async fn ensure_local_with_progress(
1385 &self,
1386 id: &str,
1387 sink: &ProgressSink,
1388 ) -> Result<PathBuf, InferenceError> {
1389 self.acquire_and_ensure(id, sink, false, None).await
1390 }
1391
1392 pub(crate) async fn ensure_local_with_progress_staged(
1397 &self,
1398 id: &str,
1399 sink: &ProgressSink,
1400 staging_dir: &Path,
1401 ) -> Result<PathBuf, InferenceError> {
1402 self.acquire_and_ensure(id, sink, false, Some(staging_dir))
1403 .await
1404 }
1405
1406 pub async fn redownload_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1412 self.acquire_and_ensure(id, &ProgressSink::none(), true, None)
1413 .await
1414 }
1415
1416 async fn acquire_and_ensure(
1417 &self,
1418 id: &str,
1419 sink: &ProgressSink,
1420 force: bool,
1421 managed_dir_override: Option<&Path>,
1422 ) -> Result<PathBuf, InferenceError> {
1423 let schema = self
1424 .get(id)
1425 .or_else(|| self.find_by_name(id))
1426 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1427 let model_name = schema.name.clone();
1428 let model_id = schema.id.clone();
1429 let needed_mb = schema.size_mb();
1430 let model_dir = self.models_dir.join(&schema.name);
1431
1432 let _guard = crate::download::acquire_model_lock(&model_id).await;
1434
1435 if !force && managed_dir_override.is_none() {
1439 if let Some(path) = self.try_reuse_local(schema).await? {
1440 return Ok(path);
1441 }
1442 }
1443
1444 if let Err(e) = crate::download::check_disk_space(&model_dir, needed_mb) {
1446 sink.emit(DownloadEvent::Failed { error: e.clone() });
1447 return Err(InferenceError::DownloadFailed(e));
1448 }
1449
1450 sink.emit(DownloadEvent::Started {
1451 model: model_name.clone(),
1452 total_files: 0,
1453 total_mb: needed_mb,
1454 });
1455 let (result, fetched) = crate::retire::collecting_fetches(self.ensure_local_inner(
1458 id,
1459 sink,
1460 force,
1461 managed_dir_override,
1462 ))
1463 .await;
1464 crate::retire::record_download(
1465 &self.state_root,
1466 &crate::hf_cache::hub_dir(),
1467 &model_id,
1468 &fetched,
1469 );
1470 match &result {
1471 Ok(_) => sink.emit(DownloadEvent::Completed { model: model_name }),
1472 Err(e) => sink.emit(DownloadEvent::Failed {
1473 error: e.to_string(),
1474 }),
1475 }
1476 result
1477 }
1478
1479 async fn try_reuse_local(
1480 &self,
1481 schema: &ModelSchema,
1482 ) -> Result<Option<PathBuf>, InferenceError> {
1483 match &schema.source {
1484 ModelSource::Local { .. } => {
1485 let model_dir = self.models_dir.join(&schema.name);
1486 let model_path = model_dir.join("model.gguf");
1487 let tokenizer_path = model_dir.join("tokenizer.json");
1488
1489 if crate::download::cache_file_usable(&model_path)
1490 && crate::download::cache_file_usable(&tokenizer_path)
1491 {
1492 return Ok(Some(model_dir));
1493 }
1494 }
1495 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1496 let model_dir = self.models_dir.join(&schema.name);
1497 let is_diffusers = mlx_schema_is_diffusers(schema);
1498
1499 if mlx_layout_complete(is_diffusers, &model_dir) {
1500 if auxiliary_mlx_files_missing(&schema.name, hf_repo, &model_dir) {
1501 return Ok(None);
1505 }
1506 info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1507 return Ok(Some(model_dir));
1508 }
1509
1510 if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1511 .filter(|d| mlx_layout_complete(is_diffusers, d))
1512 {
1513 if !auxiliary_mlx_files_missing(&schema.name, hf_repo, &snapshot_dir) {
1514 info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1515 return Ok(Some(snapshot_dir));
1516 }
1517 }
1518 }
1519 _ => {}
1520 }
1521
1522 Ok(None)
1523 }
1524
1525 async fn ensure_local_inner(
1526 &self,
1527 id: &str,
1528 sink: &ProgressSink,
1529 force: bool,
1530 managed_dir_override: Option<&Path>,
1531 ) -> Result<PathBuf, InferenceError> {
1532 let schema = self
1533 .get(id)
1534 .or_else(|| self.find_by_name(id))
1535 .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1536
1537 match &schema.source {
1538 ModelSource::Local {
1539 hf_repo,
1540 hf_filename,
1541 tokenizer_repo,
1542 } => {
1543 let model_dir = managed_dir_override
1544 .map(Path::to_path_buf)
1545 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1546 let model_path = model_dir.join("model.gguf");
1547 let tokenizer_path = model_dir.join("tokenizer.json");
1548
1549 if !force
1550 && crate::download::cache_file_usable(&model_path)
1551 && crate::download::cache_file_usable(&tokenizer_path)
1552 {
1553 return Ok(model_dir);
1554 }
1555
1556 if hf_repo.is_empty() {
1563 let missing = [
1564 ("model.gguf", &model_path),
1565 ("tokenizer.json", &tokenizer_path),
1566 ]
1567 .into_iter()
1568 .filter(|(_, path)| !crate::download::cache_file_usable(path))
1569 .map(|(name, _)| name)
1570 .collect::<Vec<_>>()
1571 .join(" and ");
1572 return Err(InferenceError::InferenceFailed(format!(
1573 "{}: discovered on disk at {} but not loadable — the GGUF \
1574 backend reads `model.gguf` and `tokenizer.json` from the \
1575 model directory, and this one is missing {missing}. Rename \
1576 the weight file to `model.gguf` and add the tokenizer, or \
1577 register the model against its HuggingFace repo so CAR can \
1578 fetch both.",
1579 schema.name,
1580 model_dir.display()
1581 )));
1582 }
1583
1584 std::fs::create_dir_all(&model_dir)?;
1585
1586 if !crate::download::cache_file_usable(&model_path) {
1587 info!(model = %schema.name, repo = %hf_repo, "downloading model weights");
1588 sink.emit(DownloadEvent::FileStarted {
1589 filename: "model weights".into(),
1590 index: 1,
1591 total_files: 2,
1592 size_mb: schema.size_mb(),
1593 });
1594 download_file(hf_repo, hf_filename, &model_path).await?;
1595 sink.emit(DownloadEvent::FileCompleted {
1596 filename: "model weights".into(),
1597 });
1598 }
1599 if !crate::download::cache_file_usable(&tokenizer_path) {
1600 info!(model = %schema.name, repo = %tokenizer_repo, "downloading tokenizer");
1601 sink.emit(DownloadEvent::FileStarted {
1602 filename: "tokenizer".into(),
1603 index: 2,
1604 total_files: 2,
1605 size_mb: 0,
1606 });
1607 download_file(tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
1608 sink.emit(DownloadEvent::FileCompleted {
1609 filename: "tokenizer".into(),
1610 });
1611 }
1612
1613 Ok(model_dir)
1614 }
1615 ModelSource::Mlx {
1616 hf_repo,
1617 hf_weight_file,
1618 }
1619 | ModelSource::ManagedVllmMlx {
1620 hf_repo,
1621 hf_weight_file,
1622 } => {
1623 let model_dir = managed_dir_override
1624 .map(Path::to_path_buf)
1625 .unwrap_or_else(|| self.models_dir.join(&schema.name));
1626 let config_path = model_dir.join("config.json");
1627
1628 let is_diffusers = mlx_schema_is_diffusers(schema);
1639
1640 if !force && mlx_layout_complete(is_diffusers, &model_dir) {
1651 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1652 info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1653 return Ok(model_dir);
1654 }
1655
1656 if !force {
1660 if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1661 .filter(|d| mlx_layout_complete(is_diffusers, d))
1662 {
1663 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &snapshot_dir).await?;
1664 info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1665 return Ok(snapshot_dir);
1666 }
1667 }
1668
1669 std::fs::create_dir_all(&model_dir)?;
1670
1671 info!(model = %schema.name, repo = %hf_repo, "downloading MLX model");
1672
1673 if is_diffusers {
1680 download_repo_snapshot(hf_repo, &model_dir, sink).await?;
1681 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1682 if !mlx_dir_has_weights(&model_dir) {
1683 return Err(InferenceError::DownloadFailed(format!(
1684 "{hf_repo}: snapshot fetched but no component weights found"
1685 )));
1686 }
1687 info!(model = %schema.name, path = %model_dir.display(), "downloaded diffusers model");
1688 return Ok(model_dir);
1689 }
1690
1691 emit_file(sink, "config", 0, schema.size_mb());
1695 download_file(hf_repo, "config.json", &config_path).await?;
1696 download_tokenizer_assets(hf_repo, &model_dir, sink).await;
1697 let tok_config_path = model_dir.join("tokenizer_config.json");
1698 if !crate::download::cache_file_usable(&tok_config_path) {
1699 let _ = download_file(hf_repo, "tokenizer_config.json", &tok_config_path).await;
1700 }
1701
1702 if let Some(ref wf) = hf_weight_file {
1704 let wf_path = model_dir.join(wf);
1705 if !crate::download::cache_file_usable(&wf_path) {
1706 emit_file(sink, "model weights", 0, schema.size_mb());
1707 download_file(hf_repo, wf, &wf_path).await?;
1708 }
1709 } else {
1710 let single = model_dir.join("model.safetensors");
1712 if !crate::download::cache_file_usable(&single) {
1713 emit_file(sink, "model weights", 0, schema.size_mb());
1714 match download_file(hf_repo, "model.safetensors", &single).await {
1715 Ok(()) => {}
1716 Err(_) => {
1717 let index_path = model_dir.join("model.safetensors.index.json");
1719 download_file(hf_repo, "model.safetensors.index.json", &index_path)
1720 .await?;
1721
1722 let index_json: serde_json::Value =
1723 serde_json::from_str(&std::fs::read_to_string(&index_path)?)
1724 .map_err(|e| {
1725 InferenceError::InferenceFailed(format!(
1726 "parse index: {e}"
1727 ))
1728 })?;
1729
1730 if let Some(weight_map) =
1731 index_json.get("weight_map").and_then(|m| m.as_object())
1732 {
1733 let mut files: std::collections::HashSet<String> =
1734 std::collections::HashSet::new();
1735 for filename in weight_map.values() {
1736 if let Some(f) = filename.as_str() {
1737 files.insert(f.to_string());
1738 }
1739 }
1740 let shard_total = files.len() as u32;
1741 for (i, file) in files.iter().enumerate() {
1742 let dest = model_dir.join(file);
1743 if !crate::download::cache_file_usable(&dest) {
1744 info!(file = %file, "downloading weight shard");
1745 sink.emit(DownloadEvent::FileStarted {
1746 filename: format!("weights part {}", i + 1),
1747 index: (i + 1) as u32,
1748 total_files: shard_total,
1749 size_mb: 0,
1750 });
1751 download_file(hf_repo, file, &dest).await?;
1752 sink.emit(DownloadEvent::FileCompleted {
1753 filename: format!("weights part {}", i + 1),
1754 });
1755 }
1756 }
1757 }
1758 }
1759 }
1760 }
1761 }
1762
1763 ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1764
1765 let missing = missing_weight_shards(&model_dir);
1777 if !missing.is_empty() {
1778 return Err(InferenceError::DownloadFailed(format!(
1779 "{}: pull finished but {} weight shard(s) are still missing: {}. \
1780 The download was interrupted; re-run the pull to resume it.",
1781 schema.name,
1782 missing.len(),
1783 missing.join(", ")
1784 )));
1785 }
1786 if !mlx_dir_has_weights(&model_dir) {
1787 return Err(InferenceError::DownloadFailed(format!(
1788 "{}: pull finished but no usable weights are present under {}",
1789 schema.name,
1790 model_dir.display()
1791 )));
1792 }
1793 Ok(model_dir)
1794 }
1795 _ => Err(InferenceError::InferenceFailed(format!(
1796 "model {} is not local",
1797 id
1798 ))),
1799 }
1800 }
1801
1802 #[deprecated(note = "use InferenceEngine::remove_model_from_car")]
1806 pub fn remove_local(&mut self, id: &str) -> Result<(), InferenceError> {
1807 Err(InferenceError::InferenceFailed(format!(
1808 "legacy registry removal for {id} is disabled; use receipt-backed model management"
1809 )))
1810 }
1811
1812 pub fn refresh_availability(&mut self) {
1819 let parslee_oauth_available = self.session.available();
1820 self.refresh_availability_with(
1821 parslee_oauth_available,
1822 self.session.signed_out() && self.session.may_forget_session_evidence(),
1823 false,
1824 );
1825 }
1826
1827 pub fn refresh_availability_for_explicit_status(&mut self) {
1845 let parslee_oauth_available = self.session.available();
1846 self.refresh_availability_with(
1847 parslee_oauth_available,
1848 self.session.signed_out() && self.session.may_forget_session_evidence(),
1849 true,
1850 );
1851 }
1852
1853 pub(crate) fn refresh_routing_availability(
1858 &mut self,
1859 parslee_api_base: Option<&str>,
1860 parslee_signed_out: bool,
1861 ) {
1862 if let Some(api_base) = parslee_api_base {
1863 let api_base = api_base.trim_end_matches('/');
1864 for schema in self.models.values_mut() {
1865 if schema.provider.eq_ignore_ascii_case("parslee") {
1866 if let ModelSource::Proprietary {
1867 provider, endpoint, ..
1868 } = &mut schema.source
1869 {
1870 if provider.eq_ignore_ascii_case("parslee") {
1871 *endpoint = api_base.to_string();
1872 }
1873 }
1874 }
1875 }
1876 }
1877 self.refresh_availability_with(parslee_api_base.is_some(), parslee_signed_out, true);
1878 }
1879
1880 pub(crate) fn refresh_work_context_availability(&mut self, api_base: &str, available: bool) {
1883 self.refresh_availability();
1884 for schema in self.models.values_mut() {
1885 if schema.provider.eq_ignore_ascii_case("parslee") {
1886 if let ModelSource::Proprietary {
1887 provider,
1888 endpoint,
1889 auth: ProprietaryAuth::OAuth2Pkce { .. },
1890 ..
1891 } = &mut schema.source
1892 {
1893 if provider.eq_ignore_ascii_case("parslee") {
1894 *endpoint = api_base.trim_end_matches('/').to_string();
1895 schema.available = available;
1896 }
1897 }
1898 }
1899 }
1900 }
1901
1902 pub fn credential_state_is_unknown(&self, env_var: &str) -> bool {
1905 self.credential_state_unknown.contains(env_var)
1906 }
1907
1908 fn refresh_availability_with(
1909 &mut self,
1910 parslee_oauth_available: bool,
1911 clear_parslee_observations: bool,
1912 authoritative_credentials: bool,
1913 ) {
1914 let models_dir = self.models_dir.clone();
1918 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1923 let mlx_vlm_cli_present = crate::backend::mlx_vlm_cli::is_available();
1924 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1925 #[allow(unused_variables)]
1926 let mlx_vlm_cli_present = false;
1927 if clear_parslee_observations {
1932 crate::openrouter::clear_gateway_unconfigured();
1933 crate::parslee_credential::clear_credential_rejected();
1937 }
1938
1939 let mut credential_envs: std::collections::BTreeSet<String> = Default::default();
1957 let mut needs_openrouter = false;
1958 let mut needs_codex = false;
1959 for m in self.models.values() {
1960 match &m.source {
1961 ModelSource::RemoteApi {
1962 protocol: crate::schema::ApiProtocol::OpenRouter,
1963 ..
1964 } => needs_openrouter = true,
1965 ModelSource::RemoteApi { api_key_env, .. } => {
1966 credential_envs.insert(api_key_env.clone());
1967 }
1968 ModelSource::Proprietary { auth, .. } => match auth {
1969 ProprietaryAuth::ApiKeyEnv { env_var }
1970 | ProprietaryAuth::BearerTokenEnv { env_var } => {
1971 credential_envs.insert(env_var.clone());
1972 }
1973 ProprietaryAuth::ChatGptSubscription {} => needs_codex = true,
1974 ProprietaryAuth::OAuth2Pkce { .. } => {}
1975 },
1976 _ => {}
1977 }
1978 }
1979 let mut credential_unknown: std::collections::HashSet<String> =
1980 std::collections::HashSet::new();
1981 if authoritative_credentials && needs_codex {
1988 remember_codex_sign_in(car_auth::codex_credential_present_without_reading_it());
1989 }
1990 let credential_available: std::collections::HashMap<String, bool> = credential_envs
1991 .into_iter()
1992 .map(|env| {
1993 let available = if authoritative_credentials {
1994 match credential_presence(&env) {
1998 CredentialPresence::Present => true,
1999 CredentialPresence::Absent => false,
2000 CredentialPresence::Unknown => {
2001 credential_unknown.insert(env.clone());
2002 false
2003 }
2004 }
2005 } else {
2006 environment_credential_available(&env)
2007 };
2008 (env, available)
2009 })
2010 .collect();
2011 self.credential_state_unknown = credential_unknown;
2012 let openrouter_available = needs_openrouter
2015 && if authoritative_credentials {
2016 crate::openrouter::refresh_credential_source().is_some()
2017 } else {
2018 crate::openrouter::credential_source().is_some()
2019 };
2020
2021 for m in self.models.values_mut() {
2022 match &m.source {
2023 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2024 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2033 {
2034 let needs_mlx_vlm = m.tags.iter().any(|t| t == "requires-mlx-vlm");
2041
2042 let in_process_loader_present = !mlx_row_needs_in_process_loader(m)
2050 || crate::backend::local::in_process_loader_available();
2051
2052 m.available = if needs_mlx_vlm {
2053 mlx_vlm_cli_present
2054 } else if m.tags.contains(&"speech".to_string()) {
2055 speech_mlx_available()
2056 } else {
2057 let mlx_dir = models_dir.join(&m.name);
2066 in_process_loader_present
2067 && (mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty())
2068 };
2069 }
2070 #[cfg(not(all(
2071 target_os = "macos",
2072 target_arch = "aarch64",
2073 not(car_skip_mlx)
2074 )))]
2075 {
2076 let _ = hf_repo; m.available = false;
2078 }
2079 }
2080 ModelSource::Local {
2081 hf_repo: local_repo,
2082 ..
2083 } => {
2084 let local_path = models_dir.join(&m.name).join("model.gguf");
2085 #[cfg(not(all(
2100 target_os = "macos",
2101 target_arch = "aarch64",
2102 not(car_skip_mlx)
2103 )))]
2104 {
2105 m.available = local_path.exists() || !local_repo.is_empty();
2106 }
2107 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2112 {
2113 let _ = local_repo;
2114 m.available = local_path.exists();
2115 }
2116 }
2117 ModelSource::WhisperCpp { .. } => {
2118 m.available = true;
2123 }
2124 ModelSource::WindowsSpeech {} => {
2125 #[cfg(target_os = "windows")]
2128 {
2129 m.available = true;
2130 }
2131 #[cfg(not(target_os = "windows"))]
2132 {
2133 m.available = false;
2134 }
2135 }
2136 ModelSource::RemoteApi {
2137 protocol: crate::schema::ApiProtocol::OpenRouter,
2138 ..
2139 } => {
2140 m.available = openrouter_available;
2141 }
2142 ModelSource::RemoteApi { api_key_env, .. } => {
2143 m.available = credential_available
2146 .get(api_key_env)
2147 .copied()
2148 .unwrap_or(false);
2149 }
2150 ModelSource::CodexCli { .. } => {
2151 m.available = crate::backend::codex_cli::is_available();
2152 }
2153 ModelSource::Ollama { .. } => {
2154 m.available = true;
2156 }
2157 ModelSource::VllmMlx { .. } => {
2158 m.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || m.available;
2162 }
2163 ModelSource::Proprietary { provider, auth, .. } => {
2164 m.available = proprietary_auth_available(
2165 &m.id,
2166 &m.provider,
2167 provider,
2168 auth,
2169 parslee_oauth_available,
2170 &credential_available,
2171 );
2172 }
2173 ModelSource::AppleFoundationModels { .. } => {
2174 #[cfg(any(
2181 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2182 all(target_os = "ios", target_arch = "aarch64")
2183 ))]
2184 {
2185 m.available = crate::backend::foundation_models::is_available();
2186 if crate::backend::foundation_models::supports_parallel_tool_calls()
2199 && !m.capabilities.contains(&ModelCapability::MultiToolCall)
2200 {
2201 m.capabilities.push(ModelCapability::MultiToolCall);
2202 }
2203 if crate::backend::foundation_models::supports_vision()
2212 && !m.capabilities.contains(&ModelCapability::Vision)
2213 {
2214 m.capabilities.push(ModelCapability::Vision);
2215 }
2216 if let Some(window) = crate::backend::foundation_models::context_size() {
2224 m.context_length = window as usize;
2225 }
2226 }
2227 #[cfg(not(any(
2228 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2229 all(target_os = "ios", target_arch = "aarch64")
2230 )))]
2231 {
2232 m.available = false;
2233 }
2234 }
2235 ModelSource::Delegated { .. } => {
2236 m.available = crate::runner::current_inference_runner().is_some();
2241 }
2242 }
2243 m.weights_ready = physical_weights_ready(m, &models_dir);
2244 }
2245 }
2246
2247 pub fn save_user_config(&self) -> Result<(), InferenceError> {
2249 let mut user_models: Vec<ModelSchema> = self
2250 .user_config_ids
2251 .iter()
2252 .filter_map(|id| self.models.get(id))
2253 .cloned()
2254 .map(|mut model| {
2255 model.mark_user_registered();
2258 model
2259 })
2260 .collect();
2261 user_models.sort_by(|a, b| a.id.cmp(&b.id));
2262
2263 for model in &user_models {
2264 crate::catalog_identity::row_digest(model).map_err(|error| {
2265 InferenceError::InferenceFailed(format!(
2266 "refuse to persist model without canonical catalog identity: {error}"
2267 ))
2268 })?;
2269 }
2270
2271 let json = serde_json::to_string_pretty(&user_models)
2272 .map_err(|e| InferenceError::InferenceFailed(format!("serialize: {e}")))?;
2273 std::fs::write(&self.user_config_path, json)?;
2274 Ok(())
2275 }
2276
2277 pub fn load_user_config(&mut self) -> Result<(), InferenceError> {
2279 if !self.user_config_path.exists() {
2280 return Ok(());
2281 }
2282
2283 let json = std::fs::read_to_string(&self.user_config_path)?;
2284 let models: Vec<ModelSchema> = serde_json::from_str(&json)
2285 .map_err(|e| InferenceError::InferenceFailed(format!("parse models.json: {e}")))?;
2286
2287 for m in models {
2288 self.register_user_model(m);
2291 }
2292 Ok(())
2293 }
2294
2295 pub fn models_dir(&self) -> &Path {
2297 &self.models_dir
2298 }
2299
2300 pub fn ready_without_download(&self, id: &str) -> Option<bool> {
2308 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2309 Some(match &schema.source {
2310 ModelSource::Local { .. } => {
2311 let model_dir = self.models_dir.join(&schema.name);
2312 crate::download::cache_file_usable(&model_dir.join("model.gguf"))
2313 && crate::download::cache_file_usable(&model_dir.join("tokenizer.json"))
2314 }
2315 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2316 let managed_dir = self.models_dir.join(&schema.name);
2317 let managed_ready = mlx_snapshot_complete(schema, &managed_dir)
2318 && crate::download::cache_file_usable(&managed_dir.join("tokenizer.json"));
2319 let snapshot_ready =
2320 latest_huggingface_repo_snapshot(hf_repo).is_some_and(|snapshot| {
2321 mlx_snapshot_complete(schema, &snapshot)
2322 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2323 });
2324 managed_ready || snapshot_ready
2325 }
2326 ModelSource::WindowsSpeech {} => true, ModelSource::WhisperCpp { model } => {
2328 car_whisper::model_cached(model)
2332 }
2333 ModelSource::RemoteApi { .. }
2334 | ModelSource::CodexCli { .. }
2335 | ModelSource::Ollama { .. }
2336 | ModelSource::VllmMlx { .. }
2337 | ModelSource::AppleFoundationModels { .. }
2338 | ModelSource::Proprietary { .. }
2339 | ModelSource::Delegated { .. } => true,
2340 })
2341 }
2342
2343 pub fn existing_local_artifact(&self, id: &str) -> Option<PathBuf> {
2347 let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2348 let managed = self.models_dir.join(&schema.name);
2349 if std::fs::symlink_metadata(&managed).is_ok()
2350 && self.ready_without_download(&schema.id) == Some(true)
2351 {
2352 return Some(managed);
2353 }
2354 match &schema.source {
2355 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2356 latest_huggingface_repo_snapshot(hf_repo).filter(|snapshot| {
2357 mlx_snapshot_complete(schema, snapshot)
2358 && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2359 })
2360 }
2361 _ => None,
2362 }
2363 }
2364
2365 fn load_builtin_catalog(&mut self) {
2367 for schema in builtin_catalog() {
2368 let id = schema.id.clone();
2369 if self.register_project_model(schema) {
2370 self.builtin_model_ids.insert(id);
2371 }
2372 }
2373 }
2374}
2375
2376fn synthesize_local_schema(name: &str, dir: &Path) -> Option<ModelSchema> {
2386 let lower = name.to_ascii_lowercase();
2387
2388 const NON_TEXT_HINTS: &[&str] = &[
2391 "vad",
2392 "whisper",
2393 "parakeet",
2394 "kokoro",
2395 "tts",
2396 "stt",
2397 "flux",
2398 "ltx",
2399 "yume",
2400 "sd-",
2401 "stable-diffusion",
2402 "wan",
2403 "mochi",
2404 "sana",
2405 "diffusion",
2406 ];
2407 if NON_TEXT_HINTS.iter().any(|h| lower.contains(h)) {
2408 return None;
2409 }
2410
2411 let capabilities: Vec<ModelCapability> =
2414 if lower.contains("embedding") || lower.contains("embed") {
2415 vec![ModelCapability::Embed]
2416 } else if lower.contains("reranker") || lower.contains("rerank") {
2417 vec![ModelCapability::Rerank]
2418 } else {
2419 vec![
2420 ModelCapability::Generate,
2421 ModelCapability::Code,
2422 ModelCapability::Reasoning,
2423 ]
2424 };
2425
2426 let config_path = dir.join("config.json");
2428 let has_safetensors =
2429 dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists();
2430
2431 let (source, context_length, quantization) = if config_path.exists() && has_safetensors {
2432 let cfg: serde_json::Value = std::fs::read_to_string(&config_path)
2434 .ok()
2435 .and_then(|s| serde_json::from_str(&s).ok())?;
2436 let model_type = cfg
2437 .get("model_type")
2438 .and_then(|v| v.as_str())
2439 .unwrap_or("")
2440 .to_ascii_lowercase();
2441 const KNOWN_LLM_TYPES: &[&str] = &[
2448 "qwen",
2449 "qwen2",
2450 "qwen3",
2451 "qwen3_moe",
2452 "llama",
2453 "mistral",
2454 "mixtral",
2455 "gemma",
2456 "gemma2",
2457 "gemma3",
2458 "gemma4_unified",
2459 "gemma4_unified_text",
2460 "phi",
2461 "phi3",
2462 "phimoe",
2463 "starcoder2",
2464 "deepseek",
2465 "deepseek_v2",
2466 "internlm2",
2467 "cohere",
2468 "olmo",
2469 ];
2470 let recognized = KNOWN_LLM_TYPES.iter().any(|t| model_type == *t)
2471 || crate::backend::local::has_native_backend(&model_type);
2472 if !recognized {
2473 return None;
2474 }
2475 let ctx = cfg
2476 .get("max_position_embeddings")
2477 .and_then(|v| v.as_u64())
2478 .unwrap_or(32_768) as usize;
2479 let quant = cfg
2485 .get("quantization")
2486 .filter(|q| q.is_object())
2487 .and_then(|q| {
2488 let bits = q
2489 .get("bits")
2490 .and_then(|b| b.as_u64())
2491 .and_then(|b| u8::try_from(b).ok());
2492 let group_size = q
2493 .get("group_size")
2494 .and_then(|g| g.as_u64())
2495 .and_then(|g| u32::try_from(g).ok());
2496 let mode = q.get("mode").and_then(|m| m.as_str());
2497 crate::schema::Quantization::from_mlx_config(bits, group_size, mode)
2498 });
2499 (
2500 serde_json::json!({ "type": "mlx", "hf_repo": "" }),
2501 ctx,
2502 quant,
2503 )
2504 } else {
2505 let gguf = std::fs::read_dir(dir).ok().and_then(|rd| {
2506 rd.flatten().map(|e| e.path()).find(|p| {
2507 p.extension()
2508 .and_then(|x| x.to_str())
2509 .is_some_and(|x| x.eq_ignore_ascii_case("gguf"))
2510 })
2511 })?;
2512 let filename = gguf
2514 .file_name()
2515 .and_then(|n| n.to_str())
2516 .unwrap_or("model.gguf")
2517 .to_string();
2518 let quant = crate::schema::Quantization::from_gguf_filename(&filename);
2522 (
2523 serde_json::json!({
2524 "type": "local",
2525 "hf_repo": "",
2526 "hf_filename": filename,
2527 "tokenizer_repo": "",
2528 }),
2529 4_096,
2530 quant,
2531 )
2532 };
2533
2534 let id = format!("local/{}", lower.replace(['/', ' '], "-"));
2535 serde_json::from_value(serde_json::json!({
2536 "id": id,
2537 "name": name,
2538 "provider": "local",
2539 "family": "local",
2540 "capabilities": capabilities,
2541 "context_length": context_length,
2542 "quantization": quantization,
2543 "source": source,
2544 "tags": ["auto-discovered"],
2545 "trust_tier": "community",
2546 }))
2547 .ok()
2548}
2549
2550#[allow(dead_code)]
2569fn speech_mlx_available() -> bool {
2570 let runtime_root = speech_runtime_root();
2574 crate::managed_venv::venv_program(&runtime_root, "mlx_audio.stt.generate").exists()
2575 || crate::managed_venv::venv_program(&runtime_root, "mlx_audio.tts.generate").exists()
2576}
2577
2578#[allow(dead_code)]
2580fn speech_runtime_root() -> PathBuf {
2581 if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
2582 if !path.trim().is_empty() {
2583 return PathBuf::from(path);
2584 }
2585 }
2586 std::env::var_os("HOME")
2587 .or_else(|| std::env::var_os("USERPROFILE"))
2588 .map(PathBuf::from)
2589 .unwrap_or_else(|| PathBuf::from("."))
2590 .join(".car")
2591 .join("speech-runtime")
2592}
2593
2594#[derive(Debug, Clone, Serialize, Deserialize)]
2596pub struct ModelInfo {
2597 pub id: String,
2598 pub name: String,
2599 pub provider: String,
2600 pub capabilities: Vec<ModelCapability>,
2601 pub param_count: String,
2602 pub size_mb: u64,
2603 pub context_length: usize,
2604 pub available: bool,
2605 pub is_local: bool,
2606 #[serde(default)]
2610 pub operator_managed_external_runtime: bool,
2611 #[serde(default)]
2619 pub weights_ready: bool,
2620 #[serde(default)]
2633 pub downloads_weights: bool,
2634 #[serde(default)]
2638 pub max_output_tokens: Option<usize>,
2639 #[serde(default)]
2643 pub public_benchmarks: Vec<crate::schema::BenchmarkScore>,
2644 #[serde(default)]
2659 pub cost: crate::schema::CostModel,
2660 #[serde(default = "default_true")]
2661 pub car_enabled: bool,
2662 #[serde(default)]
2663 pub can_remove: bool,
2664 #[serde(default)]
2665 pub in_use: bool,
2666 #[serde(default)]
2667 pub management_evidence: Option<String>,
2668 #[serde(default)]
2679 pub fit: crate::recommend::ModelFitStatus,
2680 #[serde(default)]
2683 pub estimated_peak_mb: Option<u64>,
2684 #[serde(default = "default_true")]
2693 pub platform_compatible: bool,
2694 #[serde(default)]
2698 pub deprecated: bool,
2699 #[serde(default)]
2737 pub credential_required: Option<String>,
2738 #[serde(default)]
2740 pub unavailable_reason: Option<String>,
2741 #[serde(default)]
2747 pub family: Option<String>,
2748 #[serde(default)]
2752 pub version: Option<String>,
2753 #[serde(default)]
2759 pub source_kind: String,
2760}
2761
2762pub fn model_unavailable_reason(info: &ModelInfo) -> Option<&'static str> {
2765 (!info.available && info.provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER))
2766 .then_some(crate::schema::OPENAI_CODEX_SIGN_IN_HINT)
2767}
2768
2769fn default_true() -> bool {
2770 true
2771}
2772
2773impl ModelInfo {
2774 pub fn with_fit(mut self, fit: crate::recommend::ModelFit) -> Self {
2778 self.fit = fit.fit;
2779 self.estimated_peak_mb = fit.estimated_peak_mb;
2780 self.platform_compatible = fit.platform_compatible;
2781 self
2782 }
2783}
2784
2785impl From<&ModelSchema> for ModelInfo {
2786 fn from(s: &ModelSchema) -> Self {
2787 let mut info = ModelInfo {
2788 id: s.id.clone(),
2789 name: s.name.clone(),
2790 provider: s.provider.clone(),
2791 capabilities: s.capabilities.clone(),
2792 param_count: s.param_count.clone(),
2793 size_mb: s.size_mb(),
2794 context_length: s.context_length,
2795 available: s.available_now(),
2796 is_local: s.is_local(),
2797 operator_managed_external_runtime: matches!(s.source, ModelSource::VllmMlx { .. }),
2798 weights_ready: s.weights_ready,
2799 downloads_weights: s.downloads_weights(),
2800 max_output_tokens: s.max_output_tokens,
2801 public_benchmarks: s.public_benchmarks.clone(),
2802 cost: s.cost.clone(),
2807 car_enabled: true,
2808 can_remove: false,
2809 in_use: false,
2810 management_evidence: None,
2811 fit: crate::recommend::ModelFitStatus::Unknown,
2816 estimated_peak_mb: None,
2817 platform_compatible: true,
2818 credential_required: s.credential_required(),
2819 unavailable_reason: None,
2820 deprecated: s.deprecated,
2821 family: s.is_local().then(|| s.family.clone()),
2824 version: s.is_local().then(|| s.version.clone()),
2825 source_kind: s.source.kind().to_string(),
2826 };
2827 info.unavailable_reason = model_unavailable_reason(&info).map(str::to_owned);
2828 info
2829 }
2830}
2831
2832fn emit_file(sink: &ProgressSink, name: &str, index: u32, size_mb: u64) {
2837 sink.emit(DownloadEvent::FileStarted {
2838 filename: name.to_string(),
2839 index,
2840 total_files: 0,
2841 size_mb,
2842 });
2843}
2844
2845async fn download_repo_snapshot(
2854 repo: &str,
2855 model_dir: &Path,
2856 sink: &ProgressSink,
2857) -> Result<(), InferenceError> {
2858 #[derive(serde::Deserialize)]
2859 struct RepoInfo {
2860 siblings: Vec<Sibling>,
2861 }
2862 #[derive(serde::Deserialize)]
2863 struct Sibling {
2864 rfilename: String,
2865 }
2866 let url = format!("https://huggingface.co/api/models/{repo}");
2867 let info: RepoInfo = crate::tls_client::model_download_client()
2872 .get(&url)
2873 .send()
2874 .await
2875 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2876 .error_for_status()
2877 .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2878 .json()
2879 .await
2880 .map_err(|e| InferenceError::DownloadFailed(format!("parse {repo} file list: {e}")))?;
2881
2882 let files: Vec<String> = info
2883 .siblings
2884 .into_iter()
2885 .map(|s| s.rfilename)
2886 .filter(|f| !f.starts_with('.') && !f.to_ascii_lowercase().ends_with(".md"))
2887 .collect();
2888 if files.is_empty() {
2889 return Err(InferenceError::DownloadFailed(format!(
2890 "{repo}: repo lists no downloadable files"
2891 )));
2892 }
2893
2894 let total = files.len() as u32;
2895 for (i, fname) in files.iter().enumerate() {
2896 let dest = model_dir.join(fname);
2897 if crate::download::cache_file_usable(&dest) {
2898 continue;
2899 }
2900 if let Some(parent) = dest.parent() {
2901 std::fs::create_dir_all(parent)?;
2902 }
2903 sink.emit(DownloadEvent::FileStarted {
2904 filename: fname.clone(),
2905 index: (i + 1) as u32,
2906 total_files: total,
2907 size_mb: 0,
2908 });
2909 download_file(repo, fname, &dest).await?;
2910 sink.emit(DownloadEvent::FileCompleted {
2911 filename: fname.clone(),
2912 });
2913 }
2914 Ok(())
2915}
2916
2917const TOKENIZER_FILENAMES: &[&str] = &[
2928 "tokenizer.json",
2929 "vocab.json",
2930 "merges.txt",
2931 "tokenizer.model",
2932 "tokenizer.vocab",
2933 "vocab.txt",
2934];
2935
2936async fn download_tokenizer_assets(hf_repo: &str, model_dir: &Path, sink: &ProgressSink) {
2956 if TOKENIZER_FILENAMES
2958 .iter()
2959 .any(|f| crate::download::cache_file_usable(&model_dir.join(f)))
2960 {
2961 return;
2962 }
2963 emit_file(sink, "tokenizer", 0, 0);
2964 let mut fetched: Vec<&str> = Vec::new();
2965 for name in TOKENIZER_FILENAMES {
2966 let dest = model_dir.join(name);
2967 if crate::download::cache_file_usable(&dest) {
2968 continue;
2969 }
2970 if download_file(hf_repo, name, &dest).await.is_ok() {
2971 fetched.push(name);
2972 }
2973 }
2974 if fetched.is_empty() {
2975 tracing::debug!(
2977 repo = %hf_repo,
2978 "no tokenizer assets in this repo; continuing (the backend may not need one)"
2979 );
2980 } else {
2981 tracing::debug!(repo = %hf_repo, files = ?fetched, "fetched tokenizer assets");
2982 }
2983}
2984
2985async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
2986 if crate::download::cache_file_usable(dest) {
2990 return Ok(());
2991 }
2992
2993 let api = crate::hf_cache::api()
2994 .build()
2995 .map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
2996
2997 let already_cached = hf_hub::Cache::new(crate::hf_cache::hub_dir())
3000 .repo(hf_hub::Repo::model(repo.to_string()))
3001 .get(filename)
3002 .is_some();
3003 let repo_id = repo;
3004 let repo = api.model(repo.to_string());
3005 let path = repo
3006 .get(filename)
3007 .await
3008 .map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
3009 if !already_cached {
3010 crate::retire::note_fetched(repo_id, filename, crate::retire::snapshot_revision(&path));
3011 }
3012
3013 install_fetched_file(&path, dest)
3014}
3015
3016fn install_fetched_file(src: &Path, dest: &Path) -> Result<(), InferenceError> {
3018 if crate::download::cache_file_usable(dest) {
3019 return Ok(());
3020 }
3021
3022 match std::fs::symlink_metadata(dest) {
3023 Ok(_) => std::fs::remove_file(dest).map_err(|e| {
3024 InferenceError::DownloadFailed(format!(
3025 "remove unusable destination {}: {e}",
3026 dest.display()
3027 ))
3028 })?,
3029 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3030 Err(error) => {
3031 return Err(InferenceError::DownloadFailed(format!(
3032 "inspect destination {}: {error}",
3033 dest.display()
3034 )));
3035 }
3036 }
3037
3038 #[cfg(unix)]
3042 {
3043 if std::os::unix::fs::symlink(src, dest).is_ok() {
3044 return Ok(());
3045 }
3046 }
3047
3048 static INSTALL_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3049 let sequence = INSTALL_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3050 let file_name = dest
3051 .file_name()
3052 .and_then(|name| name.to_str())
3053 .unwrap_or("download");
3054 let temp = dest.with_file_name(format!(
3055 ".{file_name}.car-install-{}-{sequence}.tmp",
3056 std::process::id()
3057 ));
3058 let _ = std::fs::remove_file(&temp);
3059 std::fs::copy(src, &temp).map_err(|error| {
3060 InferenceError::DownloadFailed(format!(
3061 "copy to temporary destination {}: {error}",
3062 temp.display()
3063 ))
3064 })?;
3065 if let Err(error) = std::fs::rename(&temp, dest) {
3066 let _ = std::fs::remove_file(&temp);
3067 return Err(InferenceError::DownloadFailed(format!(
3068 "publish downloaded file at {}: {error}",
3069 dest.display()
3070 )));
3071 }
3072 Ok(())
3073}
3074
3075pub(crate) const FLUX_AUXILIARY: (&str, &str, &str, &str) = (
3080 "mlx-community/Flux-1.lite-8B-MLX-Q4",
3081 "Flux-1.lite-8B-MLX-Q4",
3082 "Freepik/flux.1-lite-8B",
3083 "tokenizer_2/tokenizer.json",
3084);
3085
3086pub(crate) const LTX_TEXT_ENCODER: &str = "mlx-community/gemma-3-12b-it-4bit";
3092
3093pub(crate) fn uses_ltx_text_encoder(schema: &ModelSchema) -> bool {
3096 matches!(schema.source, ModelSource::Mlx { .. })
3097 && schema.has_capability(ModelCapability::VideoGeneration)
3098}
3099
3100pub(crate) fn uses_flux_auxiliary(model_name: &str, hf_repo: &str) -> bool {
3101 hf_repo == FLUX_AUXILIARY.0 || model_name == FLUX_AUXILIARY.1
3102}
3103
3104fn auxiliary_mlx_files_missing(model_name: &str, hf_repo: &str, model_dir: &Path) -> bool {
3105 uses_flux_auxiliary(model_name, hf_repo)
3106 && !crate::download::cache_file_usable(
3107 &model_dir.join("tokenizer_2").join("tokenizer.json"),
3108 )
3109}
3110
3111async fn ensure_auxiliary_mlx_files(
3112 model_name: &str,
3113 hf_repo: &str,
3114 model_dir: &Path,
3115) -> Result<(), InferenceError> {
3116 if auxiliary_mlx_files_missing(model_name, hf_repo, model_dir) {
3117 let t5_tokenizer_path = model_dir.join("tokenizer_2").join("tokenizer.json");
3118 std::fs::create_dir_all(
3119 t5_tokenizer_path
3120 .parent()
3121 .ok_or_else(|| InferenceError::InferenceFailed("invalid tokenizer path".into()))?,
3122 )?;
3123 info!(
3124 path = %t5_tokenizer_path.display(),
3125 "downloading missing Flux tokenizer_2/tokenizer.json from base model"
3126 );
3127 download_file(FLUX_AUXILIARY.2, FLUX_AUXILIARY.3, &t5_tokenizer_path).await?;
3128 }
3129 Ok(())
3130}
3131
3132fn mlx_auxiliary_ready_without_download(model_name: &str, model_dir: &Path) -> bool {
3133 if model_name == "Flux-1.lite-8B-MLX-Q4" {
3134 return crate::download::cache_file_usable(
3135 &model_dir.join("tokenizer_2").join("tokenizer.json"),
3136 );
3137 }
3138 true
3139}
3140
3141fn physical_weights_ready(schema: &ModelSchema, models_dir: &Path) -> bool {
3144 physical_weights_ready_with_huggingface_hub(schema, models_dir, None)
3145}
3146
3147pub(crate) fn physical_weights_ready_with_huggingface_hub(
3148 schema: &ModelSchema,
3149 models_dir: &Path,
3150 huggingface_hub_root: Option<&Path>,
3151) -> bool {
3152 match &schema.source {
3153 ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
3154 let managed_dir = models_dir.join(&schema.name);
3155 if mlx_snapshot_complete(schema, &managed_dir) {
3156 return true;
3157 }
3158 let shared_snapshot = match huggingface_hub_root {
3159 Some(root) => latest_huggingface_repo_snapshot_in(&crate::hf_cache::repo_dir_in(
3160 root, hf_repo,
3161 )),
3162 None => latest_huggingface_repo_snapshot(hf_repo),
3163 };
3164 shared_snapshot
3165 .as_deref()
3166 .is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3167 }
3168 ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
3169 ModelSource::Local { .. } => {
3170 crate::download::cache_file_usable(&models_dir.join(&schema.name).join("model.gguf"))
3171 }
3172 ModelSource::WindowsSpeech {}
3176 | ModelSource::AppleFoundationModels { .. }
3177 | ModelSource::VllmMlx { .. }
3178 | ModelSource::Ollama { .. }
3179 | ModelSource::RemoteApi { .. }
3180 | ModelSource::CodexCli { .. }
3181 | ModelSource::Proprietary { .. }
3182 | ModelSource::Delegated { .. } => true,
3183 }
3184}
3185
3186#[cfg(test)]
3187fn mlx_weights_ready_at(
3188 schema: &ModelSchema,
3189 managed_dir: &Path,
3190 shared_snapshot: Option<&Path>,
3191) -> bool {
3192 mlx_snapshot_complete(schema, managed_dir)
3193 || shared_snapshot.is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3194}
3195
3196fn mlx_snapshot_complete(schema: &ModelSchema, dir: &Path) -> bool {
3203 mlx_layout_complete(mlx_schema_is_diffusers(schema), dir)
3204 && mlx_auxiliary_ready_without_download(&schema.name, dir)
3205}
3206
3207fn mlx_schema_is_diffusers(schema: &ModelSchema) -> bool {
3211 schema.capabilities.iter().any(|capability| {
3212 matches!(
3213 capability,
3214 ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
3215 )
3216 })
3217}
3218
3219fn mlx_layout_complete(is_diffusers: bool, dir: &Path) -> bool {
3228 (is_diffusers || crate::download::cache_file_usable(&dir.join("config.json")))
3229 && mlx_dir_has_weights(dir)
3230}
3231
3232#[allow(dead_code)]
3252fn mlx_row_needs_in_process_loader(schema: &ModelSchema) -> bool {
3253 use crate::schema::ModelCapability as C;
3254 let tagged = |t: &str| schema.tags.iter().any(|x| x == t);
3255 if tagged("speech") || tagged("requires-mlx-vlm") {
3256 return false;
3257 }
3258 if schema
3259 .capabilities
3260 .iter()
3261 .any(|c| matches!(c, C::ImageGeneration | C::VideoGeneration))
3262 {
3263 return false;
3264 }
3265 matches!(schema.source, ModelSource::Mlx { .. })
3266}
3267
3268pub(crate) fn mlx_dir_has_weights(dir: &Path) -> bool {
3290 let index = dir.join("model.safetensors.index.json");
3294 if index.is_file() {
3295 return sharded_weight_files(&index).is_some_and(|required| {
3296 !required.is_empty()
3297 && required
3298 .iter()
3299 .all(|shard| crate::download::cache_file_usable(&dir.join(shard)))
3300 });
3301 }
3302 mlx_dir_has_weights_depth(dir, 0)
3303}
3304
3305pub(crate) fn missing_weight_shards(dir: &Path) -> Vec<String> {
3317 let index = dir.join("model.safetensors.index.json");
3318 if !index.is_file() {
3319 return Vec::new();
3320 }
3321 let Some(required) = sharded_weight_files(&index) else {
3322 return Vec::new();
3325 };
3326 required
3327 .into_iter()
3328 .filter(|shard| !dir.join(shard).exists())
3329 .collect()
3330}
3331
3332fn sharded_weight_files(index: &Path) -> Option<Vec<String>> {
3339 let raw = std::fs::read_to_string(index).ok()?;
3340 let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
3341 let map = parsed.get("weight_map")?.as_object()?;
3342 let mut files: Vec<String> = map
3343 .values()
3344 .filter_map(|v| v.as_str().map(str::to_string))
3345 .collect();
3346 files.sort();
3347 files.dedup();
3348 Some(files)
3349}
3350
3351fn mlx_dir_has_weights_depth(dir: &Path, depth: usize) -> bool {
3362 if depth > 4 {
3363 return false;
3364 }
3365 let Ok(rd) = std::fs::read_dir(dir) else {
3366 return false;
3367 };
3368 rd.flatten().any(|e| {
3369 let p = e.path();
3370 let is_symlink = std::fs::symlink_metadata(&p)
3371 .map(|m| m.file_type().is_symlink())
3372 .unwrap_or(true);
3373 if p.is_dir() {
3374 !is_symlink && mlx_dir_has_weights_depth(&p, depth + 1)
3375 } else {
3376 p.extension().and_then(|x| x.to_str()) == Some("safetensors")
3380 && crate::download::cache_file_usable(&p)
3381 }
3382 })
3383}
3384
3385#[allow(dead_code)] fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
3387 latest_huggingface_repo_snapshot(repo_id).is_some()
3388}
3389
3390fn resolve_huggingface_ref_snapshot(repo_dir: &Path, name: &str) -> Option<PathBuf> {
3391 let sha = std::fs::read_to_string(repo_dir.join("refs").join(name))
3392 .ok()?
3393 .trim()
3394 .to_string();
3395 if sha.is_empty() {
3396 return None;
3397 }
3398
3399 let snapshot = repo_dir.join("snapshots").join(sha);
3400 if snapshot_looks_ready(&snapshot) {
3401 Some(snapshot)
3402 } else {
3403 None
3404 }
3405}
3406
3407pub(crate) fn latest_huggingface_repo_snapshot(repo_id: &str) -> Option<PathBuf> {
3408 let repo_dir = crate::hf_cache::repo_dir(repo_id);
3409 latest_huggingface_repo_snapshot_in(&repo_dir)
3410}
3411
3412fn latest_huggingface_repo_snapshot_in(repo_dir: &Path) -> Option<PathBuf> {
3413 if let Some(snapshot) = resolve_huggingface_ref_snapshot(repo_dir, "main") {
3414 return Some(snapshot);
3415 }
3416
3417 let snapshots = repo_dir.join("snapshots");
3418 let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(snapshots)
3419 .ok()?
3420 .filter_map(Result::ok)
3421 .map(|e| e.path())
3422 .filter(|p| p.is_dir() && snapshot_looks_ready(p))
3423 .map(|path| {
3424 let modified = path
3425 .metadata()
3426 .and_then(|metadata| metadata.modified())
3427 .unwrap_or(SystemTime::UNIX_EPOCH);
3428 (modified, path)
3429 })
3430 .collect();
3431 candidates.sort();
3432 candidates.pop().map(|(_, path)| path)
3433}
3434
3435fn snapshot_looks_ready(path: &Path) -> bool {
3436 if path.join("config.json").exists() || path.join("model_index.json").exists() {
3437 return true;
3438 }
3439 snapshot_contains_ext(path, "safetensors")
3440}
3441
3442fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
3443 let Ok(entries) = std::fs::read_dir(root) else {
3444 return false;
3445 };
3446 entries.filter_map(Result::ok).any(|entry| {
3447 let path = entry.path();
3448 if path.is_dir() {
3449 snapshot_contains_ext(&path, ext)
3450 } else {
3451 let ext_matches = path
3452 .extension()
3453 .and_then(|value| value.to_str())
3454 .map(|value| value.eq_ignore_ascii_case(ext))
3455 .unwrap_or(false);
3456 ext_matches && crate::download::cache_file_usable(&path)
3460 }
3461 })
3462}
3463
3464const BUILTIN_CATALOG_JSON: &str = include_str!("builtin_catalog.json");
3473
3474static BUILTIN_CATALOG: std::sync::LazyLock<Vec<ModelSchema>> = std::sync::LazyLock::new(|| {
3475 serde_json::from_str(BUILTIN_CATALOG_JSON)
3476 .expect("builtin_catalog.json failed to parse — fix the JSON, not this code")
3477});
3478
3479pub(crate) fn builtin_catalog() -> Vec<ModelSchema> {
3480 let mut catalog = BUILTIN_CATALOG.clone();
3481 catalog.extend(crate::openrouter::builtin_schemas());
3482 catalog
3483}
3484
3485pub fn builtin_credential_env_names() -> std::collections::BTreeSet<String> {
3491 let mut names = std::collections::BTreeSet::new();
3492 for model in builtin_catalog() {
3493 match model.source {
3494 ModelSource::RemoteApi {
3495 api_key_env,
3496 api_key_envs,
3497 ..
3498 } => {
3499 names.insert(api_key_env);
3500 names.extend(api_key_envs);
3501 }
3502 ModelSource::Proprietary { auth, .. } => match auth {
3503 ProprietaryAuth::ApiKeyEnv { env_var }
3504 | ProprietaryAuth::BearerTokenEnv { env_var } => {
3505 names.insert(env_var);
3506 }
3507 ProprietaryAuth::OAuth2Pkce { .. } | ProprietaryAuth::ChatGptSubscription {} => {}
3508 },
3509 ModelSource::Local { .. }
3510 | ModelSource::CodexCli { .. }
3511 | ModelSource::Ollama { .. }
3512 | ModelSource::Mlx { .. }
3513 | ModelSource::WhisperCpp { .. }
3514 | ModelSource::WindowsSpeech {}
3515 | ModelSource::VllmMlx { .. }
3516 | ModelSource::ManagedVllmMlx { .. }
3517 | ModelSource::AppleFoundationModels { .. }
3518 | ModelSource::Delegated { .. } => {}
3519 }
3520 }
3521 names
3522}
3523
3524pub fn is_builtin_subscription_billed(model_id: &str) -> bool {
3532 let latest_id = (!model_id.contains(':')).then(|| format!("{model_id}:latest"));
3533 builtin_catalog().into_iter().any(|model| {
3534 (model.id == model_id || latest_id.as_deref() == Some(model.id.as_str()))
3535 && model.is_subscription_billed()
3536 })
3537}
3538
3539#[doc(hidden)]
3542pub fn builtin_catalog_with_huggingface_hub_for_testing(
3543 models_dir: &Path,
3544 huggingface_hub_root: &Path,
3545) -> Vec<ModelSchema> {
3546 let mut catalog = builtin_catalog();
3547 for schema in &mut catalog {
3548 schema.weights_ready = physical_weights_ready_with_huggingface_hub(
3549 schema,
3550 models_dir,
3551 Some(huggingface_hub_root),
3552 );
3553 }
3554 catalog
3555}
3556
3557#[cfg(test)]
3558mod tests {
3559 use crate::openrouter::StateRootScope;
3560
3561 #[test]
3569 fn a_sharded_model_missing_one_shard_is_not_installed() {
3570 let tmp = tempfile::tempdir().unwrap();
3571 let dir = tmp.path();
3572 std::fs::write(
3573 dir.join("model.safetensors.index.json"),
3574 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
3575 "b":"model-00002-of-00002.safetensors"}}"#,
3576 )
3577 .unwrap();
3578 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
3580 assert!(
3581 !mlx_dir_has_weights(dir),
3582 "a missing shard must read as not-installed, or pull silently no-ops"
3583 );
3584
3585 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
3587 assert!(
3588 mlx_dir_has_weights(dir),
3589 "a complete shard set must read as installed"
3590 );
3591 }
3592
3593 #[test]
3601 fn missing_shards_are_reported_by_name() {
3602 let tmp = tempfile::tempdir().unwrap();
3603 let dir = tmp.path();
3604 std::fs::write(
3605 dir.join("model.safetensors.index.json"),
3606 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
3607 "b":"model-00002-of-00002.safetensors"}}"#,
3608 )
3609 .unwrap();
3610 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
3611
3612 assert_eq!(
3613 missing_weight_shards(dir),
3614 vec!["model-00001-of-00002.safetensors".to_string()],
3615 "the absent shard must be named, not just counted"
3616 );
3617
3618 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
3619 assert!(
3620 missing_weight_shards(dir).is_empty(),
3621 "a complete shard set must report nothing missing"
3622 );
3623 }
3624
3625 #[test]
3629 fn missing_shards_is_empty_without_an_index() {
3630 let tmp = tempfile::tempdir().unwrap();
3631 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
3632 assert!(missing_weight_shards(tmp.path()).is_empty());
3633
3634 let bad = tempfile::tempdir().unwrap();
3637 std::fs::write(bad.path().join("model.safetensors.index.json"), b"not json").unwrap();
3638 assert!(missing_weight_shards(bad.path()).is_empty());
3639 }
3640
3641 #[test]
3643 fn a_single_file_model_still_counts_without_an_index() {
3644 let tmp = tempfile::tempdir().unwrap();
3645 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
3646 assert!(mlx_dir_has_weights(tmp.path()));
3647 }
3648
3649 #[test]
3652 fn an_unparseable_index_fails_closed() {
3653 let tmp = tempfile::tempdir().unwrap();
3654 std::fs::write(
3655 tmp.path().join("model.safetensors.index.json"),
3656 b"{not-json",
3657 )
3658 .unwrap();
3659 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
3660 assert!(
3661 !mlx_dir_has_weights(tmp.path()),
3662 "an unreadable index must not fall back to a stray weight"
3663 );
3664 }
3665
3666 use super::*;
3667 use tempfile::TempDir;
3668
3669 #[test]
3670 fn mlx_dir_has_weights_detects_completeness() {
3671 let tmp = TempDir::new().unwrap();
3672 let dir = tmp.path();
3673
3674 std::fs::write(dir.join("config.json"), "{}").unwrap();
3676 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
3677 assert!(
3678 !mlx_dir_has_weights(dir),
3679 "config-only stub must not count as installed"
3680 );
3681
3682 std::fs::write(dir.join("model.safetensors.index.json"), "{}").unwrap();
3684 assert!(!mlx_dir_has_weights(dir), "index.json alone is not weights");
3685
3686 std::fs::remove_file(dir.join("model.safetensors.index.json")).unwrap();
3688 std::fs::write(dir.join("model.safetensors"), b"\x00\x01\x02").unwrap();
3689 assert!(mlx_dir_has_weights(dir));
3690 }
3691
3692 #[test]
3693 fn mlx_dir_has_weights_handles_sharded_and_dangling_symlinks() {
3694 let sharded = TempDir::new().unwrap();
3695 std::fs::write(sharded.path().join("config.json"), "{}").unwrap();
3696 std::fs::write(
3697 sharded.path().join("model-00001-of-00002.safetensors"),
3698 b"\x00",
3699 )
3700 .unwrap();
3701 assert!(mlx_dir_has_weights(sharded.path()), "sharded shard counts");
3702
3703 #[cfg(unix)]
3706 {
3707 let dangling = TempDir::new().unwrap();
3708 std::fs::write(dangling.path().join("config.json"), "{}").unwrap();
3709 std::os::unix::fs::symlink(
3710 dangling.path().join("does-not-exist"),
3711 dangling.path().join("model.safetensors"),
3712 )
3713 .unwrap();
3714 assert!(
3715 !mlx_dir_has_weights(dangling.path()),
3716 "dangling weight symlink must count as absent"
3717 );
3718 }
3719 }
3720
3721 struct TestRegistry {
3743 registry: UnifiedRegistry,
3744 _tmp: TempDir,
3748 _environment: tokio::sync::MutexGuard<'static, ()>,
3749 }
3750
3751 impl std::ops::Deref for TestRegistry {
3752 type Target = UnifiedRegistry;
3753
3754 fn deref(&self) -> &Self::Target {
3755 &self.registry
3756 }
3757 }
3758
3759 impl std::ops::DerefMut for TestRegistry {
3760 fn deref_mut(&mut self) -> &mut Self::Target {
3761 &mut self.registry
3762 }
3763 }
3764
3765 fn test_registry() -> TestRegistry {
3766 let _environment = crate::openrouter::test_environment_scope();
3767 let tmp = TempDir::new().unwrap();
3768 let registry = UnifiedRegistry::new_with_state_root(
3769 tmp.path().to_path_buf(),
3770 tmp.path().join("models"),
3771 );
3772 TestRegistry {
3773 registry,
3774 _tmp: tmp,
3775 _environment,
3776 }
3777 }
3778
3779 #[test]
3793 fn an_unreadable_credential_is_recorded_as_unknown_not_absent() {
3794 let registry = UnifiedRegistry::new_empty(std::path::PathBuf::from("/nonexistent"));
3798 assert!(
3799 !registry.credential_state_is_unknown("VENDOR_API_KEY"),
3800 "an unprobed registry must not claim a credential state is unknown"
3801 );
3802
3803 assert_eq!(
3807 credential_presence("CAR_TEST_CREDENTIAL_THAT_DOES_NOT_EXIST"),
3808 CredentialPresence::Absent
3809 );
3810 }
3811
3812 #[test]
3813 fn credential_required_names_a_key_only_when_a_key_is_the_answer() {
3814 let remote = |available: bool, deprecated: bool| {
3815 let mut m = test_generate_schema(
3816 "vendor/m",
3817 "m",
3818 ModelSource::RemoteApi {
3819 protocol: crate::schema::ApiProtocol::OpenAiCompat,
3820 endpoint: "https://example.invalid/v1".into(),
3821 api_key_env: "VENDOR_API_KEY".into(),
3822 api_key_envs: vec![],
3823 api_version: None,
3824 },
3825 );
3826 m.provider = "vendor".into();
3827 m.available = available;
3828 m.deprecated = deprecated;
3829 m
3830 };
3831
3832 assert_eq!(
3833 remote(false, false).credential_required().as_deref(),
3834 Some("VENDOR_API_KEY"),
3835 "an unavailable plain remote row is exactly the case this field is for"
3836 );
3837
3838 assert_eq!(
3839 remote(true, false).credential_required(),
3840 None,
3841 "an available row needs nothing"
3842 );
3843 assert_eq!(
3844 remote(false, true).credential_required(),
3845 None,
3846 "a deprecated row does not become usable by adding a key"
3847 );
3848
3849 let mut multi = remote(false, false);
3853 if let ModelSource::RemoteApi {
3854 ref mut api_key_envs,
3855 ..
3856 } = multi.source
3857 {
3858 *api_key_envs = vec!["VENDOR_A".into(), "VENDOR_B".into()];
3859 }
3860 assert_eq!(multi.credential_required(), None);
3861
3862 let mut router = remote(false, false);
3865 router.source = ModelSource::RemoteApi {
3866 protocol: crate::schema::ApiProtocol::OpenRouter,
3867 endpoint: "https://openrouter.ai/api/v1".into(),
3868 api_key_env: "OPENROUTER_API_KEY".into(),
3869 api_key_envs: vec![],
3870 api_version: None,
3871 };
3872 assert_eq!(router.credential_required(), None);
3873
3874 let mut local = test_generate_schema(
3876 "mlx/local",
3877 "local",
3878 ModelSource::Mlx {
3879 hf_repo: "org/repo".into(),
3880 hf_weight_file: None,
3881 },
3882 );
3883 local.available = false;
3884 assert_eq!(local.credential_required(), None);
3885 }
3886
3887 fn test_generate_schema(id: &str, name: &str, source: ModelSource) -> ModelSchema {
3888 ModelSchema {
3889 id: id.into(),
3890 name: name.into(),
3891 provider: "local".into(),
3892 family: "qwen3".into(),
3893 version: "test".into(),
3894 capabilities: vec![ModelCapability::Generate],
3895 context_length: 4096,
3896 max_output_tokens: None,
3897 param_count: String::new(),
3898 quantization: None,
3899 performance: PerformanceEnvelope::default(),
3900 cost: CostModel::default(),
3901 source,
3902 tags: vec![],
3903 supported_params: vec![],
3904 public_benchmarks: vec![],
3905 trust_tier: crate::schema::TrustTier::Curated,
3906 deprecated: false,
3907 available: false,
3908 weights_ready: false,
3909 }
3910 }
3911
3912 #[test]
3917 fn model_info_carries_weights_ready_through_the_projection() {
3918 let mut schema = test_generate_schema(
3919 "mlx-community/car894-test-4bit",
3920 "car894-test-4bit",
3921 ModelSource::Mlx {
3922 hf_repo: "mlx-community/car894-test-4bit".into(),
3923 hf_weight_file: None,
3924 },
3925 );
3926
3927 schema.weights_ready = false;
3928 assert!(
3929 !ModelInfo::from(&schema).weights_ready,
3930 "a schema with no weights on disk must project weights_ready = false"
3931 );
3932
3933 schema.weights_ready = true;
3934 assert!(
3935 ModelInfo::from(&schema).weights_ready,
3936 "a schema with weights on disk must project weights_ready = true"
3937 );
3938 }
3939
3940 #[test]
3946 fn model_info_carries_downloads_weights_through_the_projection() {
3947 let mlx = test_generate_schema(
3948 "mlx-community/car894-test-4bit",
3949 "car894-test-4bit",
3950 ModelSource::Mlx {
3951 hf_repo: "mlx-community/car894-test-4bit".into(),
3952 hf_weight_file: None,
3953 },
3954 );
3955 assert!(
3956 ModelInfo::from(&mlx).downloads_weights,
3957 "an MLX entry downloads weights"
3958 );
3959
3960 for (label, source) in [
3963 ("windows speech", ModelSource::WindowsSpeech {}),
3964 (
3965 "apple foundation",
3966 ModelSource::AppleFoundationModels { use_case: None },
3967 ),
3968 ] {
3969 let schema = test_generate_schema("car894/os-model", "os-model", source);
3970 let info = ModelInfo::from(&schema);
3971 assert!(
3972 !info.downloads_weights,
3973 "{label} installs nothing, so the projection must say so"
3974 );
3975 assert!(
3976 info.is_local,
3977 "{label} is still local — which is exactly why is_local cannot stand in"
3978 );
3979 }
3980
3981 let external = test_generate_schema(
3986 "car894/external-model",
3987 "external-model",
3988 ModelSource::VllmMlx {
3989 endpoint: "http://localhost:8000".into(),
3990 model_name: "mlx-community/car894-test-4bit".into(),
3991 },
3992 );
3993 assert!(!external.is_local());
3994 assert!(external.is_remote());
3995 assert!(!external.requires_apple_silicon());
3996 let info = ModelInfo::from(&external);
3997 assert!(!info.is_local);
3998 assert!(
3999 !info.downloads_weights,
4000 "external vllm-mlx owns its weights, so CAR installs nothing"
4001 );
4002 }
4003
4004 #[test]
4005 fn model_info_classifies_only_raw_vllm_mlx_as_operator_managed_external() {
4006 for endpoint in ["http://localhost:8000", "https://models.example.invalid/v1"] {
4007 let schema = test_generate_schema(
4008 "external/model",
4009 "external-model",
4010 ModelSource::VllmMlx {
4011 endpoint: endpoint.into(),
4012 model_name: "mlx-community/external-model".into(),
4013 },
4014 );
4015 let info = ModelInfo::from(&schema);
4016 assert!(info.operator_managed_external_runtime);
4017 assert_eq!(
4018 serde_json::to_value(info).unwrap()["operator_managed_external_runtime"],
4019 true
4020 );
4021 }
4022
4023 for source in [
4024 ModelSource::RemoteApi {
4025 endpoint: "https://cloud.example.invalid/v1".into(),
4026 api_key_env: "CAR_TEST_KEY".into(),
4027 api_key_envs: vec![],
4028 api_version: None,
4029 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4030 },
4031 ModelSource::ManagedVllmMlx {
4032 hf_repo: "mlx-community/car-owned-model".into(),
4033 hf_weight_file: None,
4034 },
4035 ] {
4036 assert!(
4037 !ModelInfo::from(&test_generate_schema(
4038 "not-external/model",
4039 "not-external-model",
4040 source,
4041 ))
4042 .operator_managed_external_runtime
4043 );
4044 }
4045 }
4046
4047 #[test]
4054 fn fresh_machine_mlx_entry_is_available_but_not_weights_ready() {
4055 let mut reg = test_registry();
4056 let id = "mlx-community/car894-fresh-4bit";
4057 reg.register(test_generate_schema(
4058 id,
4059 "car894-fresh-4bit",
4060 ModelSource::Mlx {
4061 hf_repo: "mlx-community/car894-fresh-4bit".into(),
4062 hf_weight_file: None,
4063 },
4064 ));
4065
4066 let registered = reg
4067 .get(id)
4068 .expect("the model just registered must be in the registry");
4069 let info = ModelInfo::from(registered);
4070
4071 assert!(
4073 !registered.weights_ready,
4074 "an empty models dir means no weights on disk"
4075 );
4076 assert!(
4077 !info.weights_ready,
4078 "the CLI-facing projection must report the same: nothing installed"
4079 );
4080
4081 #[cfg(car_mlxlm_swift_built)]
4082 {
4083 assert!(
4086 registered.available,
4087 "a declared hf_repo makes an MLX entry runnable before download (#164)"
4088 );
4089 assert!(
4090 info.available,
4091 "the projection must keep reporting it as runnable"
4092 );
4093 }
4094 #[cfg(not(car_mlxlm_swift_built))]
4095 {
4096 assert!(
4099 !registered.available,
4100 "the Swift MLX backend is absent, so it must not be runnable"
4101 );
4102 assert!(!info.available);
4103 }
4104 }
4105
4106 fn write_complete_mlx_snapshot(dir: &Path) {
4107 std::fs::create_dir_all(dir).unwrap();
4108 std::fs::write(dir.join("config.json"), b"{}").unwrap();
4109 std::fs::write(dir.join("tokenizer.json"), b"{}").unwrap();
4110 std::fs::write(
4111 dir.join("model.safetensors.index.json"),
4112 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
4113 )
4114 .unwrap();
4115 std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"one").unwrap();
4116 std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
4117 }
4118
4119 #[test]
4120 fn complete_managed_and_shared_mlx_snapshots_are_physically_ready() {
4121 let schema = test_generate_schema(
4122 "mlx/qwen3-4b:4bit",
4123 "Qwen3-4B-MLX",
4124 ModelSource::Mlx {
4125 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
4126 hf_weight_file: None,
4127 },
4128 );
4129 let root = tempfile::tempdir().unwrap();
4130 let managed = root.path().join("managed");
4131 let shared = root.path().join("shared");
4132
4133 write_complete_mlx_snapshot(&managed);
4134 assert!(mlx_weights_ready_at(&schema, &managed, None));
4135
4136 std::fs::remove_dir_all(&managed).unwrap();
4137 write_complete_mlx_snapshot(&shared);
4138 assert!(mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4139 }
4140
4141 #[test]
4142 fn zero_byte_gguf_is_not_physically_ready() {
4143 let schema = test_generate_schema(
4144 "qwen/qwen3-4b:q4_k_m",
4145 "Qwen3-4B",
4146 ModelSource::Local {
4147 hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
4148 hf_filename: "model.gguf".into(),
4149 tokenizer_repo: "Qwen/Qwen3-4B".into(),
4150 },
4151 );
4152 let root = tempfile::tempdir().unwrap();
4153 let model_dir = root.path().join(&schema.name);
4154 std::fs::create_dir_all(&model_dir).unwrap();
4155 std::fs::write(model_dir.join("model.gguf"), b"").unwrap();
4156
4157 assert!(!physical_weights_ready(&schema, root.path()));
4158 std::fs::write(model_dir.join("model.gguf"), b"gguf").unwrap();
4159 assert!(physical_weights_ready(&schema, root.path()));
4160 }
4161
4162 #[test]
4163 fn shared_mlx_snapshot_missing_an_indexed_shard_is_not_physically_ready() {
4164 let schema = test_generate_schema(
4165 "mlx/qwen3-8b:4bit",
4166 "Qwen3-8B-MLX",
4167 ModelSource::Mlx {
4168 hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
4169 hf_weight_file: None,
4170 },
4171 );
4172 let root = tempfile::tempdir().unwrap();
4173 let managed = root.path().join("managed");
4174 let shared = root.path().join("shared");
4175 write_complete_mlx_snapshot(&shared);
4176 std::fs::remove_file(shared.join("model-00001-of-00002.safetensors")).unwrap();
4177
4178 assert!(!mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4179 }
4180
4181 #[test]
4213 fn a_gateway_that_reports_no_upstream_stops_being_advertised() {
4214 let _guard = crate::openrouter::test_environment_scope();
4215 let _home = StateRootScope::new();
4218 crate::openrouter::clear_gateway_unconfigured();
4219
4220 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
4221 .into_iter()
4222 .filter(|s| crate::openrouter::is_curated_managed_gateway_alias(&s.id))
4223 .collect();
4224 assert!(
4225 !managed.is_empty(),
4226 "precondition: the curated catalog must still carry managed aliases"
4227 );
4228
4229 let availability_of = |schema: &ModelSchema| match &schema.source {
4230 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
4231 &schema.id,
4232 &schema.provider,
4233 provider,
4234 auth,
4235 true,
4237 &std::collections::HashMap::new(),
4238 ),
4239 other => panic!("managed aliases must be Proprietary, got {other:?}"),
4240 };
4241
4242 assert!(
4243 managed.iter().all(availability_of),
4244 "precondition: an authenticated session advertises these today"
4245 );
4246
4247 crate::openrouter::note_gateway_unconfigured();
4248 assert!(
4249 managed.iter().all(|s| !availability_of(s)),
4250 "after the gateway says it has no OpenRouter upstream, every alias in \
4251 the namespace must report unavailable — that claim is what cost the \
4252 benchmark sweep in #786"
4253 );
4254
4255 crate::openrouter::clear_gateway_unconfigured();
4259 assert!(
4260 managed.iter().all(availability_of),
4261 "the suppression must be recoverable, not a one-way latch"
4262 );
4263 }
4264
4265 #[test]
4279 fn constructing_a_registry_with_a_live_session_leaves_the_gateway_observation_alone() {
4280 let _guard = crate::openrouter::test_environment_scope();
4281 let home = StateRootScope::new();
4282
4283 crate::openrouter::note_gateway_unconfigured();
4284 let _registry = UnifiedRegistry::new_with_session(
4285 home.path().to_path_buf(),
4286 home.path().join("models"),
4287 None,
4288 SessionProbe::Fixed(true),
4289 );
4290
4291 let observed = crate::openrouter::gateway_unconfigured();
4292 let persisted = crate::openrouter::gateway_state_path().exists();
4293
4294 crate::openrouter::clear_gateway_unconfigured();
4295
4296 assert!(
4297 observed,
4298 "a signed-in session has no reason to forget what the gateway said"
4299 );
4300 assert!(
4301 persisted,
4302 "the durable half of the observation must survive construction too"
4303 );
4304 }
4305
4306 #[test]
4314 fn constructing_a_registry_with_no_session_still_forgets_the_gateway_observation() {
4315 let _guard = crate::openrouter::test_environment_scope();
4316 let home = StateRootScope::new();
4317
4318 crate::openrouter::note_gateway_unconfigured();
4319 let recorded = crate::openrouter::gateway_unconfigured();
4320 let _registry = UnifiedRegistry::new_with_session(
4321 home.path().to_path_buf(),
4322 home.path().join("models"),
4323 None,
4324 SessionProbe::Fixed(false),
4325 );
4326
4327 let observed = crate::openrouter::gateway_unconfigured();
4328 let persisted = crate::openrouter::gateway_state_path().exists();
4329
4330 crate::openrouter::clear_gateway_unconfigured();
4331
4332 assert!(
4333 recorded,
4334 "precondition: the observation is on record before construction"
4335 );
4336 assert!(
4337 !observed,
4338 "sign-out must still discard the session-scoped verdict (#786)"
4339 );
4340 assert!(
4341 !persisted,
4342 "and the durable copy with it — otherwise the next sign-in inherits it from disk"
4343 );
4344 }
4345
4346 #[test]
4363 fn an_ordinary_test_registry_does_not_disturb_a_separately_set_observation() {
4364 let _guard = crate::openrouter::test_environment_scope();
4365 let home = StateRootScope::new();
4366
4367 crate::openrouter::note_gateway_unconfigured();
4368 let _registry = UnifiedRegistry::new_with_state_root(
4369 home.path().to_path_buf(),
4370 home.path().join("models"),
4371 );
4372
4373 let observed = crate::openrouter::gateway_unconfigured();
4374
4375 crate::openrouter::clear_gateway_unconfigured();
4376
4377 assert!(
4378 observed,
4379 "constructing a registry is not a statement about the session, so it \
4380 must not erase an observation another test just recorded (#986)"
4381 );
4382 assert!(
4383 !SessionProbe::Inert.may_forget_session_evidence(),
4384 "the `cfg(test)` construction default must be a probe that answers \
4385 the session question without acting on it — this is the half of \
4386 the guarantee that does not depend on whether the runner happens \
4387 to be signed in"
4388 );
4389 }
4390
4391 #[test]
4404 fn a_rejected_credential_stops_the_managed_lane_being_advertised() {
4405 let _guard = crate::openrouter::test_environment_scope();
4406 let _home = StateRootScope::new();
4407 crate::parslee_credential::clear_credential_rejected();
4408
4409 let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
4410 .into_iter()
4411 .filter(|s| s.provider == "parslee")
4412 .collect();
4413 assert!(
4414 !managed.is_empty(),
4415 "precondition: the curated catalog must still carry parslee rows"
4416 );
4417
4418 let availability_of = |schema: &ModelSchema| match &schema.source {
4419 ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
4420 &schema.id,
4421 &schema.provider,
4422 provider,
4423 auth,
4424 true,
4426 &std::collections::HashMap::new(),
4427 ),
4428 other => panic!("parslee rows must be Proprietary, got {other:?}"),
4429 };
4430
4431 assert!(
4432 managed.iter().all(availability_of),
4433 "precondition: an authenticated session advertises these today"
4434 );
4435
4436 crate::parslee_credential::note_credential_rejected();
4437 assert!(
4438 managed.iter().all(|s| !availability_of(s)),
4439 "after the server rejects the credential, EVERY parslee row must \
4440 report unavailable — unlike the gateway verdict this is not scoped \
4441 to the curated OpenRouter aliases, because a dead credential kills \
4442 the whole namespace"
4443 );
4444
4445 crate::parslee_credential::clear_credential_rejected();
4448 assert!(
4449 managed.iter().all(availability_of),
4450 "the suppression must lift once the credential works again"
4451 );
4452 }
4453
4454 #[test]
4471 fn an_explicit_status_refresh_sees_the_same_environment_credential() {
4472 let _environment = crate::openrouter::test_environment_scope();
4473 let tmp = TempDir::new().unwrap();
4474 let env_var = "CAR_TEST_EXPLICIT_STATUS_KEY";
4475 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
4476 let mut schema = test_generate_schema(
4477 "vendor/explicit-status",
4478 "explicit-status",
4479 ModelSource::RemoteApi {
4480 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4481 endpoint: "https://example.invalid/v1".into(),
4482 api_key_env: env_var.into(),
4483 api_key_envs: vec![],
4484 api_version: None,
4485 },
4486 );
4487 schema.provider = "vendor".into();
4488 registry.register(schema);
4489
4490 std::env::remove_var(env_var);
4491 registry.refresh_availability();
4492 assert!(
4493 !registry.get("vendor/explicit-status").unwrap().available,
4494 "no credential anywhere: the passive refresh must report unavailable"
4495 );
4496 registry.refresh_availability_for_explicit_status();
4497 assert!(
4498 !registry.get("vendor/explicit-status").unwrap().available,
4499 "no credential anywhere: the explicit refresh must agree"
4500 );
4501
4502 std::env::set_var(env_var, "sk-present");
4503 registry.refresh_availability();
4504 assert!(
4505 registry.get("vendor/explicit-status").unwrap().available,
4506 "exported credential: the passive refresh must see it"
4507 );
4508 registry.refresh_availability_for_explicit_status();
4509 assert!(
4510 registry.get("vendor/explicit-status").unwrap().available,
4511 "exported credential: the explicit refresh must see it too — if this fails \
4512 the authoritative mode is resolving through a path that cannot see the \
4513 environment, and `car models list` is answering from something else again"
4514 );
4515 std::env::remove_var(env_var);
4516 }
4517
4518 #[test]
4519 fn refresh_availability_probes_each_credential_once_not_per_model() {
4520 let _environment = crate::openrouter::test_environment_scope();
4533 let tmp = TempDir::new().unwrap();
4534 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
4535 for i in 0..25 {
4536 let mut schema = test_generate_schema(
4537 &format!("openrouter/model-{i}"),
4538 &format!("model-{i}"),
4539 ModelSource::RemoteApi {
4540 protocol: crate::schema::ApiProtocol::OpenRouter,
4541 endpoint: "https://openrouter.ai/api/v1".into(),
4542 api_key_env: "OPENROUTER_API_KEY".into(),
4543 api_key_envs: vec![],
4544 api_version: None,
4545 },
4546 );
4547 schema.provider = "openrouter".into();
4548 registry.register(schema);
4549 }
4550
4551 crate::openrouter::reset_credential_source_call_count();
4552 registry.refresh_availability();
4553 let calls = crate::openrouter::credential_source_call_count();
4554
4555 assert_eq!(
4556 calls, 1,
4557 "refresh_availability probed the OpenRouter credential {calls} times for 25 models; \
4558 it must resolve each distinct credential once per refresh, not once per model"
4559 );
4560 }
4561
4562 #[test]
4568 fn a_vllm_mlx_pull_targets_the_shared_huggingface_cache() {
4569 let repo = "mlx-community/Qwen3.8-27B-4bit";
4570 let dir = crate::hf_cache::repo_dir(repo);
4571 assert!(
4572 dir.ends_with("models--mlx-community--Qwen3.8-27B-4bit"),
4573 "got {}",
4574 dir.display()
4575 );
4576 assert!(
4577 dir.parent().is_some_and(|p| p.ends_with("hub")),
4578 "must live under the HF cache's hub/ root, got {}",
4579 dir.display()
4580 );
4581 }
4582
4583 #[test]
4586 fn external_vllm_mlx_does_not_become_managed_from_a_loopback_endpoint() {
4587 let _environment = crate::openrouter::test_environment_scope();
4600 let tmp = TempDir::new().unwrap();
4601 let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
4602 let schema = test_generate_schema(
4603 "vllm-mlx/arch-the-rust-backend-cannot-load",
4604 "external-only-model",
4605 ModelSource::VllmMlx {
4606 endpoint: "http://localhost:8000".into(),
4607 model_name: "mlx-community/Qwen3.8-27B-4bit".into(),
4608 },
4609 );
4610 registry.register(schema);
4611
4612 assert!(
4614 std::env::var("VLLM_MLX_ENDPOINT").is_err(),
4615 "test precondition: VLLM_MLX_ENDPOINT must be unset"
4616 );
4617 registry.refresh_availability();
4618
4619 let model = registry
4620 .get("vllm-mlx/arch-the-rust-backend-cannot-load")
4621 .expect("registered model should be present");
4622 assert!(
4623 !model.available,
4624 "an external vllm-mlx row remains external even on loopback; only an \
4625 explicit ManagedVllmMlx source may use CAR's runtime"
4626 );
4627 }
4628
4629 #[test]
4630 fn user_config_load_and_save_force_community_trust() {
4631 let tmp = TempDir::new().unwrap();
4632 let models_dir = tmp.path().join("models");
4633 let config_path = tmp.path().join("models.json");
4634 let schema = test_generate_schema(
4635 "user/test-model",
4636 "user-test-model",
4637 ModelSource::RemoteApi {
4638 endpoint: "https://attacker.invalid/v1/chat/completions".into(),
4639 api_key_env: "CAR_USER_MODEL_TEST_KEY".into(),
4640 api_key_envs: vec![],
4641 api_version: None,
4642 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4643 },
4644 );
4645 let mut omitted_tier = serde_json::to_value(schema.clone()).unwrap();
4646 omitted_tier.as_object_mut().unwrap().remove("trust_tier");
4647 std::fs::write(
4648 &config_path,
4649 serde_json::to_vec_pretty(&vec![omitted_tier]).unwrap(),
4650 )
4651 .unwrap();
4652
4653 let mut loaded = UnifiedRegistry::new_empty(models_dir.clone());
4654 loaded.load_user_config().unwrap();
4655 assert_eq!(
4656 loaded.get("user/test-model").unwrap().trust_tier,
4657 crate::schema::TrustTier::Community
4658 );
4659
4660 let mut persisted = UnifiedRegistry::new_empty(models_dir);
4661 persisted.register_user_model(schema);
4662 persisted.save_user_config().unwrap();
4663 let saved: Vec<ModelSchema> =
4664 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
4665 assert_eq!(saved.len(), 1);
4666 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
4667 }
4668
4669 #[test]
4670 fn persisted_user_model_cannot_shadow_managed_openrouter_alias() {
4671 let _environment = crate::openrouter::test_environment_scope();
4676 let tmp = TempDir::new().unwrap();
4677 let models_dir = tmp.path().join("models");
4678 let config_path = tmp.path().join("models.json");
4679 let mut shadow = crate::openrouter::curated_schemas()
4680 .into_iter()
4681 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
4682 .unwrap();
4683 shadow.provider = "attacker".into();
4684 std::fs::write(
4685 &config_path,
4686 serde_json::to_vec_pretty(&vec![shadow]).unwrap(),
4687 )
4688 .unwrap();
4689
4690 let registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
4691 let actual = registry
4692 .get("parslee/openrouter/frontier-general")
4693 .expect("compiled managed alias must remain present");
4694 assert_eq!(actual.provider, "parslee");
4695 assert_eq!(
4696 crate::openrouter::canonical_managed_gateway_selector(actual),
4697 Some("parslee/openrouter/frontier-general")
4698 );
4699 }
4700
4701 fn signed_remote(id: &str, name: &str) -> ModelSchema {
4702 test_generate_schema(
4703 id,
4704 name,
4705 ModelSource::RemoteApi {
4706 endpoint: "https://catalog.example/v1".into(),
4707 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
4708 api_key_envs: vec![],
4709 api_version: None,
4710 protocol: crate::schema::ApiProtocol::OpenAiCompat,
4711 },
4712 )
4713 }
4714
4715 #[test]
4716 fn a_user_registered_row_wins_a_live_signed_swap_and_stays_in_models_json() {
4717 let _environment = crate::openrouter::test_environment_scope();
4718 let tmp = TempDir::new().unwrap();
4719 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
4720 tmp.path().to_path_buf(),
4721 tmp.path().join("models"),
4722 None,
4723 );
4724 let mut mine = signed_remote("shared/id", "mine");
4725 mine.context_length = 1234;
4726 registry.register_user_model(mine);
4727 let mut publishers = signed_remote("shared/id", "publishers");
4728 publishers.context_length = 9999;
4729 assert_eq!(
4730 registry.replace_signed_catalog(
4731 vec![publishers],
4732 None,
4733 &HashSet::new(),
4734 &HashSet::new()
4735 ),
4736 0
4737 );
4738 assert_eq!(registry.get("shared/id").unwrap().context_length, 1234);
4739 registry.save_user_config().unwrap();
4740 let saved = std::fs::read_to_string(tmp.path().join(USER_MODELS_FILE)).unwrap();
4741 assert!(saved.contains("1234") && !saved.contains("9999"), "{saved}");
4742 assert!(registry.unregister_user_model("shared/id").is_some());
4743 }
4744
4745 #[test]
4746 fn a_withdrawn_row_something_depends_on_stays_deprecated_until_nothing_does() {
4747 let _environment = crate::openrouter::test_environment_scope();
4748 let tmp = TempDir::new().unwrap();
4749 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
4750 tmp.path().to_path_buf(),
4751 tmp.path().join("models"),
4752 None,
4753 );
4754 let used = signed_remote("signed/used", "used");
4755 let idle = signed_remote("signed/idle", "idle");
4756 assert_eq!(
4757 registry.replace_signed_catalog(
4758 vec![used, idle],
4759 None,
4760 &HashSet::new(),
4761 &HashSet::new()
4762 ),
4763 2
4764 );
4765 let keep: HashSet<String> = ["signed/used".to_string()].into();
4766 assert_eq!(
4767 registry.replace_signed_catalog(vec![], None, &keep, &HashSet::new()),
4768 0
4769 );
4770 assert!(
4771 registry.get("signed/idle").is_none(),
4772 "nothing depended on it"
4773 );
4774 let kept = registry.get("signed/used").expect("a dependent keeps it");
4775 assert!(kept.deprecated, "kept, but never suggested");
4776 let used = signed_remote("signed/used", "used");
4778 assert_eq!(
4779 registry.replace_signed_catalog(vec![used], None, &keep, &HashSet::new()),
4780 1
4781 );
4782 assert!(!registry.get("signed/used").unwrap().deprecated);
4783 registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
4785 assert!(registry.get("signed/used").is_none());
4786 }
4787
4788 #[test]
4789 fn a_kept_row_survives_a_restart_only_while_its_signed_catalog_verifies() {
4790 let _environment = crate::openrouter::test_environment_scope();
4791 let tmp = TempDir::new().unwrap();
4792 let open = |key: Option<&str>| {
4793 UnifiedRegistry::new_with_catalog_public_key(
4794 tmp.path().to_path_buf(),
4795 tmp.path().join("models"),
4796 key,
4797 )
4798 };
4799 let cache = crate::catalog::cache_path(tmp.path());
4800 let retained = crate::catalog::retained_path(tmp.path());
4801 let catalog = |models, version| {
4802 crate::catalog::signed_test_catalog(
4803 crate::catalog::CatalogDoc {
4804 revoked: Vec::new(),
4805 version,
4806 models,
4807 },
4808 21,
4809 )
4810 };
4811 let (v1, key) = catalog(
4812 vec![
4813 signed_remote("signed/used", "used"),
4814 signed_remote("signed/idle", "idle"),
4815 ],
4816 1,
4817 );
4818 crate::catalog::save_verified(&cache, &v1).unwrap();
4819 let mut running = open(Some(&key));
4820 assert!(running.get("signed/used").is_some());
4821
4822 let (v2, _) = catalog(vec![], 2);
4824 let keep: HashSet<String> = ["signed/used".to_string()].into();
4825 running.replace_signed_catalog(
4826 v2.clone().into_models(),
4827 Some(std::sync::Arc::new(v2.envelope())),
4828 &keep,
4829 &HashSet::new(),
4830 );
4831 crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
4832 crate::catalog::save_verified(&cache, &v2).unwrap();
4833
4834 let restarted = open(Some(&key));
4835 let kept = restarted.get("signed/used").expect("kept across a restart");
4836 assert!(kept.deprecated);
4837 assert!(restarted.get("signed/idle").is_none());
4838 let (_, other_key) = crate::catalog::signed_test_catalog(
4840 crate::catalog::CatalogDoc {
4841 revoked: Vec::new(),
4842 version: 9,
4843 models: vec![],
4844 },
4845 22,
4846 );
4847 assert!(open(Some(&other_key)).get("signed/used").is_none());
4848 let json = std::fs::read_to_string(&retained).unwrap();
4850 std::fs::write(&retained, json.replace("signed/idle", "signed/idlx")).unwrap();
4851 assert!(open(Some(&key)).get("signed/used").is_none());
4852 }
4853
4854 #[test]
4855 fn a_revoked_row_leaves_even_when_something_depends_on_it() {
4856 let _environment = crate::openrouter::test_environment_scope();
4857 let tmp = TempDir::new().unwrap();
4858 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
4859 tmp.path().to_path_buf(),
4860 tmp.path().join("models"),
4861 None,
4862 );
4863 let none = HashSet::new();
4864 let bad: HashSet<String> = ["signed/bad".to_string()].into();
4865 registry.replace_signed_catalog(
4866 vec![signed_remote("signed/bad", "bad")],
4867 None,
4868 &none,
4869 &none,
4870 );
4871 registry.replace_signed_catalog(vec![], None, &bad, &none);
4873 assert!(registry.get("signed/bad").is_some());
4874 registry.replace_signed_catalog(vec![], None, &bad, &bad);
4876 assert!(registry.get("signed/bad").is_none());
4877 registry.replace_signed_catalog(
4879 vec![signed_remote("signed/bad", "bad")],
4880 None,
4881 &none,
4882 &bad,
4883 );
4884 assert!(registry.get("signed/bad").is_none());
4885 assert!(registry.retained_rows().is_empty());
4886
4887 let builtin = builtin_catalog()
4889 .into_iter()
4890 .find(|m| !m.deprecated)
4891 .expect("a current builtin");
4892 let revoked: HashSet<String> = [builtin.id.clone()].into();
4893 registry.replace_signed_catalog(vec![], None, &none, &revoked);
4894 assert!(registry.get(&builtin.id).unwrap().deprecated);
4895 registry.replace_signed_catalog(vec![], None, &none, &none);
4896 assert!(!registry.get(&builtin.id).unwrap().deprecated);
4897 }
4898
4899 #[test]
4900 fn a_restart_never_restores_a_row_the_current_catalog_revokes() {
4901 let _environment = crate::openrouter::test_environment_scope();
4902 let tmp = TempDir::new().unwrap();
4903 let catalog = |models, revoked, version| {
4904 crate::catalog::signed_test_catalog(
4905 crate::catalog::CatalogDoc {
4906 version,
4907 models,
4908 revoked,
4909 },
4910 23,
4911 )
4912 };
4913 let (v1, key) = catalog(vec![signed_remote("signed/kept", "kept")], vec![], 1);
4914 let mut running = UnifiedRegistry::new_with_catalog_public_key(
4915 tmp.path().to_path_buf(),
4916 tmp.path().join("models"),
4917 Some(&key),
4918 );
4919 running.replace_signed_catalog(
4920 v1.clone().into_models(),
4921 Some(std::sync::Arc::new(v1.envelope())),
4922 &HashSet::new(),
4923 &HashSet::new(),
4924 );
4925 let (v2, _) = catalog(vec![], vec![], 2);
4927 let keep: HashSet<String> = ["signed/kept".to_string()].into();
4928 running.replace_signed_catalog(
4929 vec![],
4930 Some(std::sync::Arc::new(v2.envelope())),
4931 &keep,
4932 &HashSet::new(),
4933 );
4934 let retained = crate::catalog::retained_path(tmp.path());
4935 crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
4936 let (v3, _) = catalog(vec![], vec!["signed/kept".to_string()], 3);
4938 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &v3).unwrap();
4939 let restarted = UnifiedRegistry::new_with_catalog_public_key(
4940 tmp.path().to_path_buf(),
4941 tmp.path().join("models"),
4942 Some(&key),
4943 );
4944 assert!(restarted.get("signed/kept").is_none());
4945 }
4946
4947 #[test]
4948 fn a_revoked_id_never_comes_back_from_the_discovery_cache() {
4949 let _environment = crate::openrouter::test_environment_scope();
4950 let tmp = TempDir::new().unwrap();
4951 let (catalog, key) = crate::catalog::signed_test_catalog(
4952 crate::catalog::CatalogDoc {
4953 version: 1,
4954 models: vec![],
4955 revoked: vec!["discovered/bad".to_string()],
4956 },
4957 24,
4958 );
4959 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &catalog).unwrap();
4960 crate::discovery::save_cache(
4963 &crate::discovery::cache_path(&tmp.path().join("models")),
4964 &[
4965 signed_remote("discovered/bad", "bad"),
4966 signed_remote("discovered/fine", "fine"),
4967 ],
4968 )
4969 .unwrap();
4970 let restarted = UnifiedRegistry::new_with_catalog_public_key(
4971 tmp.path().to_path_buf(),
4972 tmp.path().join("models"),
4973 Some(&key),
4974 );
4975 assert!(restarted.get("discovered/bad").is_none());
4976 assert!(restarted.get("discovered/fine").is_some());
4977
4978 let mut running = UnifiedRegistry::new_with_catalog_public_key(
4981 tmp.path().to_path_buf(),
4982 tmp.path().join("models"),
4983 None,
4984 );
4985 assert!(running.get("discovered/bad").is_some());
4986 let revoked: HashSet<String> = ["discovered/bad".to_string()].into();
4987 running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
4988 assert!(running.get("discovered/bad").is_none());
4989 assert!(running.get("discovered/fine").is_some());
4990
4991 running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
4993 assert!(running.get("discovered/bad").is_some());
4994 assert!(!running.is_revoked("discovered/bad"));
4995
4996 running.register_user_model(signed_remote("discovered/bad", "mine"));
4999 running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
5000 assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5001 running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5002 assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5003 }
5004
5005 #[test]
5006 fn a_signed_overlay_updates_a_builtins_measurements_never_its_weights() {
5007 let _environment = crate::openrouter::test_environment_scope();
5008 let tmp = TempDir::new().unwrap();
5009 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5010 tmp.path().to_path_buf(),
5011 tmp.path().join("models"),
5012 None,
5013 );
5014 let compiled = builtin_catalog()
5015 .into_iter()
5016 .find(|m| m.public_benchmarks.iter().any(|b| b.name == "car-judged"))
5017 .expect("a scored builtin");
5018 let mut remeasured = compiled.clone();
5019 remeasured.public_benchmarks[0].score = 0.42;
5020 remeasured.public_benchmarks[0].runs = Some(3);
5021 remeasured.context_length = compiled.context_length + 1;
5022 remeasured.name = "renamed by the publisher".into();
5023 assert_eq!(
5024 registry.replace_signed_catalog(
5025 vec![remeasured.clone()],
5026 None,
5027 &HashSet::new(),
5028 &HashSet::new()
5029 ),
5030 1
5031 );
5032 let row = registry.get(&compiled.id).unwrap();
5033 assert_eq!(row.public_benchmarks[0].score, 0.42);
5034 assert_eq!(row.public_benchmarks[0].runs, Some(3));
5035 assert_eq!(
5036 row.context_length, compiled.context_length,
5037 "not an overlay field"
5038 );
5039 assert_eq!(row.name, compiled.name, "not an overlay field");
5040 registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5042 assert_eq!(
5043 registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5044 compiled.public_benchmarks[0].score
5045 );
5046 let mut repointed = remeasured;
5048 repointed.source = signed_remote("x/y", "y").source;
5049 assert_eq!(
5050 registry.replace_signed_catalog(
5051 vec![repointed],
5052 None,
5053 &HashSet::new(),
5054 &HashSet::new()
5055 ),
5056 0
5057 );
5058 assert_eq!(
5059 registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5060 compiled.public_benchmarks[0].score
5061 );
5062 }
5063
5064 #[test]
5065 fn user_config_persistence_excludes_signed_rows_and_keeps_builtin_tagged_user_rows() {
5066 let _environment = crate::openrouter::test_environment_scope();
5071 let tmp = TempDir::new().unwrap();
5072 let models_dir = tmp.path().join("models");
5073 let config_path = tmp.path().join("models.json");
5074
5075 let signed = test_generate_schema(
5076 "signed/catalog-only",
5077 "signed-catalog-only",
5078 ModelSource::RemoteApi {
5079 endpoint: "https://catalog.example/v1".into(),
5080 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5081 api_key_envs: vec![],
5082 api_version: None,
5083 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5084 },
5085 );
5086 assert!(!signed.tags.iter().any(|tag| tag == "builtin"));
5087 let (verified, public_key) = crate::catalog::signed_test_catalog(
5088 crate::catalog::CatalogDoc {
5089 revoked: Vec::new(),
5090 version: 81,
5091 models: vec![signed],
5092 },
5093 81,
5094 );
5095 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
5096
5097 let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5098 tmp.path().to_path_buf(),
5099 models_dir.clone(),
5100 Some(public_key.as_str()),
5101 );
5102 let mut user = test_generate_schema(
5103 "user/builtin-tagged",
5104 "user-builtin-tagged",
5105 ModelSource::RemoteApi {
5106 endpoint: "https://user.example/v1".into(),
5107 api_key_env: "USER_MODEL_TEST_KEY".into(),
5108 api_key_envs: vec![],
5109 api_version: None,
5110 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5111 },
5112 );
5113 user.tags.push("builtin".into());
5114 registry.register_user_model(user);
5115 registry.save_user_config().unwrap();
5116
5117 let saved: Vec<ModelSchema> =
5118 serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
5119 assert_eq!(
5120 saved
5121 .iter()
5122 .map(|model| model.id.as_str())
5123 .collect::<Vec<_>>(),
5124 vec!["user/builtin-tagged"],
5125 "models.json must contain only explicitly user-registered rows"
5126 );
5127 assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
5128
5129 let mut restarted = UnifiedRegistry::new_with_catalog_public_key(
5130 tmp.path().to_path_buf(),
5131 models_dir,
5132 Some(public_key.as_str()),
5133 );
5134 assert_eq!(
5135 restarted.get("signed/catalog-only").unwrap().trust_tier,
5136 crate::schema::TrustTier::Curated,
5137 "user persistence must not demote an unrelated signed catalog row"
5138 );
5139 assert_eq!(
5140 restarted.get("user/builtin-tagged").unwrap().trust_tier,
5141 crate::schema::TrustTier::Community
5142 );
5143 let mut signed_shadow = restarted.get("signed/catalog-only").unwrap().clone();
5144 signed_shadow.name = "user-shadow-of-signed-row".into();
5145 restarted.register_user_model(signed_shadow);
5146 assert_eq!(
5147 restarted.get("signed/catalog-only").unwrap().name,
5148 "signed-catalog-only",
5149 "a user row must not shadow a signature-verified project exact id"
5150 );
5151 }
5152
5153 #[test]
5154 fn empty_user_config_save_clears_stale_rows() {
5155 let tmp = TempDir::new().unwrap();
5156 let models_dir = tmp.path().join("models");
5157 let config_path = tmp.path().join("models.json");
5158 let stale = test_generate_schema(
5159 "user/stale",
5160 "stale",
5161 ModelSource::RemoteApi {
5162 endpoint: "https://stale.example/v1".into(),
5163 api_key_env: "STALE_USER_MODEL_TEST_KEY".into(),
5164 api_key_envs: vec![],
5165 api_version: None,
5166 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5167 },
5168 );
5169 std::fs::write(
5170 &config_path,
5171 serde_json::to_vec_pretty(&vec![stale]).unwrap(),
5172 )
5173 .unwrap();
5174
5175 UnifiedRegistry::new_empty(models_dir)
5176 .save_user_config()
5177 .unwrap();
5178
5179 let saved: Vec<ModelSchema> =
5180 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5181 assert!(
5182 saved.is_empty(),
5183 "saving an empty user set must overwrite stale models.json rows"
5184 );
5185 }
5186
5187 #[test]
5188 fn unregister_then_save_removes_the_user_row_from_disk() {
5189 let tmp = TempDir::new().unwrap();
5190 let models_dir = tmp.path().join("models");
5191 let config_path = tmp.path().join("models.json");
5192 let mut registry = UnifiedRegistry::new_empty(models_dir);
5193 registry.register_project_model(test_generate_schema(
5194 "signed/not-user-removable",
5195 "not-user-removable",
5196 ModelSource::RemoteApi {
5197 endpoint: "https://catalog.example/v1".into(),
5198 api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5199 api_key_envs: vec![],
5200 api_version: None,
5201 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5202 },
5203 ));
5204 assert!(
5205 registry
5206 .unregister_user_model("signed/not-user-removable")
5207 .is_none(),
5208 "the user boundary cannot unregister an untracked catalog row"
5209 );
5210 assert!(registry.get("signed/not-user-removable").is_some());
5211 registry.register_user_model(test_generate_schema(
5212 "user/removable",
5213 "removable",
5214 ModelSource::RemoteApi {
5215 endpoint: "https://user.example/v1".into(),
5216 api_key_env: "REMOVABLE_USER_MODEL_TEST_KEY".into(),
5217 api_key_envs: vec![],
5218 api_version: None,
5219 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5220 },
5221 ));
5222 registry.save_user_config().unwrap();
5223 assert!(registry.unregister_user_model("user/removable").is_some());
5224 registry.save_user_config().unwrap();
5225
5226 let saved: Vec<ModelSchema> =
5227 serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5228 assert!(saved.is_empty());
5229 }
5230
5231 #[test]
5243 fn a_registration_written_under_car_home_is_the_file_the_registry_reads() {
5244 let _environment = crate::openrouter::test_environment_scope();
5245 let prior = std::env::var_os(car_home::ENV_VAR);
5246
5247 let state_root = TempDir::new().unwrap();
5248 let weights = TempDir::new().unwrap();
5251 let models_dir = weights.path().join("models");
5252 std::fs::create_dir_all(&models_dir).unwrap();
5253
5254 unsafe { std::env::set_var(car_home::ENV_VAR, state_root.path()) };
5255
5256 let write_path = user_config_path().expect("CAR_HOME must resolve a models.json path");
5258 assert_eq!(write_path, state_root.path().join(USER_MODELS_FILE));
5259 let registered = test_generate_schema(
5260 "user/relocated-daemon-model",
5261 "relocated-daemon-model",
5262 ModelSource::RemoteApi {
5263 endpoint: "https://relocated.example/v1".into(),
5264 api_key_env: "RELOCATED_DAEMON_MODEL_TEST_KEY".into(),
5265 api_key_envs: vec![],
5266 api_version: None,
5267 protocol: crate::schema::ApiProtocol::OpenAiCompat,
5268 },
5269 );
5270 std::fs::write(
5271 &write_path,
5272 serde_json::to_vec_pretty(&vec![registered]).unwrap(),
5273 )
5274 .unwrap();
5275
5276 let registry = UnifiedRegistry::new(models_dir.clone());
5278
5279 match prior {
5280 Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
5281 None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
5282 }
5283
5284 assert!(
5285 registry.get("user/relocated-daemon-model").is_some(),
5286 "the registry must load the models.json that `models.register` wrote; \
5287 it looked at {} instead",
5288 registry.user_config_path.display(),
5289 );
5290 assert_eq!(registry.user_config_path, write_path);
5291 assert!(
5292 !weights.path().join(USER_MODELS_FILE).exists(),
5293 "nothing may be written beside the shared weights cache",
5294 );
5295 }
5296
5297 #[test]
5298 fn ready_without_download_is_strict_for_local_model_files() {
5299 let tmp = TempDir::new().unwrap();
5300 let models = tmp.path().join("models");
5301 let mut reg = UnifiedRegistry::new_empty(models.clone());
5302 reg.register(test_generate_schema(
5303 "local/test",
5304 "TestLocal",
5305 ModelSource::Local {
5306 hf_repo: "example/repo".into(),
5307 hf_filename: "model.gguf".into(),
5308 tokenizer_repo: "example/repo".into(),
5309 },
5310 ));
5311
5312 assert_eq!(reg.ready_without_download("local/test"), Some(false));
5313
5314 let dir = models.join("TestLocal");
5315 std::fs::create_dir_all(&dir).unwrap();
5316 std::fs::write(dir.join("model.gguf"), b"weights").unwrap();
5317 assert_eq!(
5318 reg.ready_without_download("local/test"),
5319 Some(false),
5320 "tokenizer is required too"
5321 );
5322 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
5323 assert_eq!(reg.ready_without_download("local/test"), Some(true));
5324 }
5325
5326 #[test]
5327 fn ready_without_download_rejects_mlx_config_only_stub() {
5328 let tmp = TempDir::new().unwrap();
5329 let models = tmp.path().join("models");
5330 let mut reg = UnifiedRegistry::new_empty(models.clone());
5331 reg.register(test_generate_schema(
5332 "mlx/test",
5333 "TestMlx",
5334 ModelSource::Mlx {
5335 hf_repo: "example/repo".into(),
5336 hf_weight_file: None,
5337 },
5338 ));
5339
5340 let dir = models.join("TestMlx");
5341 std::fs::create_dir_all(&dir).unwrap();
5342 std::fs::write(dir.join("config.json"), "{}").unwrap();
5343 std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
5344 assert_eq!(
5345 reg.ready_without_download("mlx/test"),
5346 Some(false),
5347 "config/tokenizer stubs must not start assistant inference"
5348 );
5349
5350 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
5351 assert_eq!(reg.ready_without_download("mlx/test"), Some(true));
5352 }
5353
5354 fn write_mlx_dir(root: &Path, name: &str, model_type: &str) {
5355 let dir = root.join(name);
5356 std::fs::create_dir_all(&dir).unwrap();
5357 std::fs::write(
5358 dir.join("config.json"),
5359 serde_json::json!({
5360 "model_type": model_type,
5361 "max_position_embeddings": 40_960,
5362 "quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" },
5363 })
5364 .to_string(),
5365 )
5366 .unwrap();
5367 std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
5368 }
5369
5370 struct ScopedEnvVar {
5371 name: &'static str,
5372 previous: Option<std::ffi::OsString>,
5373 }
5374
5375 impl ScopedEnvVar {
5376 fn set(name: &'static str, value: &Path) -> Self {
5377 let previous = std::env::var_os(name);
5378 unsafe { std::env::set_var(name, value) };
5381 Self { name, previous }
5382 }
5383
5384 fn unset(name: &'static str) -> Self {
5385 let previous = std::env::var_os(name);
5386 unsafe { std::env::remove_var(name) };
5388 Self { name, previous }
5389 }
5390 }
5391
5392 impl Drop for ScopedEnvVar {
5393 fn drop(&mut self) {
5394 unsafe {
5397 match self.previous.take() {
5398 Some(value) => std::env::set_var(self.name, value),
5399 None => std::env::remove_var(self.name),
5400 }
5401 }
5402 }
5403 }
5404
5405 #[derive(Default)]
5406 struct AcquisitionRecorder {
5407 events: std::sync::Mutex<Vec<DownloadEvent>>,
5408 replace_dir_on_started: std::sync::Mutex<Option<PathBuf>>,
5409 }
5410
5411 impl AcquisitionRecorder {
5412 fn replacing_dir_on_started(path: PathBuf) -> Self {
5413 Self {
5414 events: std::sync::Mutex::new(Vec::new()),
5415 replace_dir_on_started: std::sync::Mutex::new(Some(path)),
5416 }
5417 }
5418
5419 fn events(&self) -> Vec<DownloadEvent> {
5420 self.events.lock().unwrap().clone()
5421 }
5422 }
5423
5424 impl crate::download::DownloadProgress for AcquisitionRecorder {
5425 fn on_event(&self, event: &DownloadEvent) {
5426 self.events.lock().unwrap().push(event.clone());
5427 if matches!(event, DownloadEvent::Started { .. }) {
5428 if let Some(path) = self.replace_dir_on_started.lock().unwrap().take() {
5429 std::fs::remove_dir_all(&path).unwrap();
5430 std::fs::write(path, b"make create_dir_all fail before any network access")
5431 .unwrap();
5432 }
5433 }
5434 }
5435 }
5436
5437 fn started_count(events: &[DownloadEvent]) -> usize {
5438 events
5439 .iter()
5440 .filter(|event| matches!(event, DownloadEvent::Started { .. }))
5441 .count()
5442 }
5443
5444 fn mlx_schema(id: &str, name: &str, hf_repo: &str) -> ModelSchema {
5445 test_generate_schema(
5446 id,
5447 name,
5448 ModelSource::Mlx {
5449 hf_repo: hf_repo.into(),
5450 hf_weight_file: None,
5451 },
5452 )
5453 }
5454
5455 #[test]
5456 fn installing_a_fetched_file_replaces_only_unusable_destinations() {
5457 let tmp = TempDir::new().unwrap();
5458 let src = tmp.path().join("fetched");
5459 std::fs::write(&src, b"fetched bytes").unwrap();
5460
5461 let missing = tmp.path().join("missing");
5462 install_fetched_file(&src, &missing).unwrap();
5463 assert_eq!(std::fs::read(&missing).unwrap(), b"fetched bytes");
5464
5465 let usable = tmp.path().join("usable");
5466 std::fs::write(&usable, b"keep these bytes").unwrap();
5467 let usable_before = std::fs::symlink_metadata(&usable).unwrap();
5468 let modified_before = usable_before.modified().unwrap();
5469 #[cfg(unix)]
5470 let inode_before = {
5471 use std::os::unix::fs::MetadataExt;
5472 usable_before.ino()
5473 };
5474 install_fetched_file(&src, &usable).unwrap();
5475 let usable_after = std::fs::symlink_metadata(&usable).unwrap();
5476 assert_eq!(std::fs::read(&usable).unwrap(), b"keep these bytes");
5477 assert_eq!(usable_after.modified().unwrap(), modified_before);
5478 #[cfg(unix)]
5479 {
5480 use std::os::unix::fs::MetadataExt;
5481 assert_eq!(usable_after.ino(), inode_before);
5482 }
5483
5484 let zero_byte = tmp.path().join("zero-byte");
5485 std::fs::write(&zero_byte, b"").unwrap();
5486 install_fetched_file(&src, &zero_byte).unwrap();
5487 assert_eq!(std::fs::read(&zero_byte).unwrap(), b"fetched bytes");
5488
5489 #[cfg(unix)]
5490 {
5491 let dangling = tmp.path().join("dangling");
5492 std::os::unix::fs::symlink(tmp.path().join("absent"), &dangling).unwrap();
5493 assert!(std::fs::symlink_metadata(&dangling).unwrap().is_symlink());
5494 install_fetched_file(&src, &dangling).unwrap();
5495 assert_eq!(std::fs::read(&dangling).unwrap(), b"fetched bytes");
5496 }
5497 }
5498
5499 #[cfg(unix)]
5502 #[tokio::test]
5503 async fn a_zero_byte_flux_auxiliary_file_is_not_accepted_as_present() {
5504 let _environment = crate::openrouter::test_environment_scope_async().await;
5505 let tmp = TempDir::new().unwrap();
5506 let home = tmp.path().join("home");
5507 let _home = ScopedEnvVar::set("HOME", &home);
5508 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5509 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5510 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5511
5512 let cached_auxiliary = tmp
5516 .path()
5517 .join("hf-home/hub")
5518 .join("models--Freepik--flux.1-lite-8B")
5519 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
5520 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
5521 std::fs::write(&cached_auxiliary, b"repaired tokenizer").unwrap();
5522 let refs = tmp
5523 .path()
5524 .join("hf-home/hub/models--Freepik--flux.1-lite-8B/refs");
5525 std::fs::create_dir_all(&refs).unwrap();
5526 std::fs::write(refs.join("main"), b"fixture").unwrap();
5527
5528 let models = tmp.path().join("models");
5529 let name = "Flux-1.lite-8B-MLX-Q4";
5530 write_mlx_dir(&models, name, "flux");
5531 let auxiliary = models.join(name).join("tokenizer_2/tokenizer.json");
5532 std::fs::create_dir_all(auxiliary.parent().unwrap()).unwrap();
5533 std::fs::write(&auxiliary, b"").unwrap();
5534 let mut reg = UnifiedRegistry::new_empty(models.clone());
5535 reg.register(mlx_schema(
5536 "mlx/flux-zero-byte-aux",
5537 name,
5538 "mlx-community/Flux-1.lite-8B-MLX-Q4",
5539 ));
5540 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5541 let sink = ProgressSink::new(recorder.clone());
5542
5543 let result = reg
5544 .acquire_and_ensure("mlx/flux-zero-byte-aux", &sink, false, None)
5545 .await;
5546 let events = recorder.events();
5547 assert!(matches!(
5548 events.first(),
5549 Some(DownloadEvent::Started { .. })
5550 ));
5551 assert_eq!(started_count(&events), 1);
5552 assert!(
5553 result.is_err() || crate::download::cache_file_usable(&auxiliary),
5554 "acquisition must replace the zero-byte auxiliary file or report failure"
5555 );
5556 if result.is_ok() {
5557 assert_eq!(std::fs::read(auxiliary).unwrap(), b"repaired tokenizer");
5558 }
5559 assert!(
5562 !tmp.path()
5563 .join("model-management")
5564 .join(crate::retire::PROVENANCE_FILE)
5565 .exists(),
5566 "reusing a cached file must not record it as a CAR download"
5567 );
5568 }
5569
5570 #[cfg(unix)]
5573 #[tokio::test]
5574 async fn managed_flux_missing_auxiliary_does_not_fall_through_to_hf_snapshot() {
5575 let _environment = crate::openrouter::test_environment_scope_async().await;
5576 let tmp = TempDir::new().unwrap();
5577 let home = tmp.path().join("home");
5578 let hf_home = tmp.path().join("hf-home");
5579 let _home = ScopedEnvVar::set("HOME", &home);
5580 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
5581 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5582 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5583
5584 let cached_auxiliary = hf_home
5588 .join("hub")
5589 .join("models--Freepik--flux.1-lite-8B")
5590 .join("snapshots/fixture/tokenizer_2/tokenizer.json");
5591 std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
5592 std::fs::write(&cached_auxiliary, b"managed repair tokenizer").unwrap();
5593 let refs = hf_home.join("hub/models--Freepik--flux.1-lite-8B/refs");
5594 std::fs::create_dir_all(&refs).unwrap();
5595 std::fs::write(refs.join("main"), b"fixture").unwrap();
5596
5597 let models = tmp.path().join("models");
5598 let name = "Flux-1.lite-8B-MLX-Q4";
5599 let managed = models.join(name);
5600 write_mlx_dir(&models, name, "flux");
5601 let managed_auxiliary = managed.join("tokenizer_2/tokenizer.json");
5602
5603 let snapshot = hf_home
5606 .join("hub/models--mlx-community--Flux-1.lite-8B-MLX-Q4")
5607 .join("snapshots/fixture");
5608 write_complete_mlx_snapshot(&snapshot);
5609 let snapshot_auxiliary = snapshot.join("tokenizer_2/tokenizer.json");
5610 std::fs::create_dir_all(snapshot_auxiliary.parent().unwrap()).unwrap();
5611 std::fs::write(&snapshot_auxiliary, b"snapshot tokenizer").unwrap();
5612
5613 let id = "mlx/flux-managed-precedence";
5614 let mut reg = UnifiedRegistry::new_empty(models);
5615 reg.register(mlx_schema(id, name, "mlx-community/Flux-1.lite-8B-MLX-Q4"));
5616 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5617 let sink = ProgressSink::new(recorder.clone());
5618
5619 let path = reg
5620 .acquire_and_ensure(id, &sink, false, None)
5621 .await
5622 .unwrap();
5623
5624 assert_eq!(path, managed);
5625 assert_eq!(
5626 std::fs::read(managed_auxiliary).unwrap(),
5627 b"managed repair tokenizer"
5628 );
5629 let events = recorder.events();
5630 assert!(matches!(
5631 events.first(),
5632 Some(DownloadEvent::Started { .. })
5633 ));
5634 assert_eq!(started_count(&events), 1);
5635 }
5636
5637 #[tokio::test]
5638 async fn reusing_a_complete_managed_mlx_dir_emits_no_acquisition_lifecycle() {
5639 let _environment = crate::openrouter::test_environment_scope_async().await;
5640 let tmp = TempDir::new().unwrap();
5641 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5642 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5643 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5644 let models = tmp.path().join("models");
5645 let mut reg = UnifiedRegistry::new_empty(models.clone());
5646 reg.register(mlx_schema(
5647 "mlx/reuse-complete",
5648 "Reuse-Complete-MLX",
5649 "example/reuse-complete",
5650 ));
5651 write_mlx_dir(&models, "Reuse-Complete-MLX", "qwen3");
5652 std::fs::write(
5653 models.join("Reuse-Complete-MLX").join("tokenizer.json"),
5654 b"{}",
5655 )
5656 .unwrap();
5657 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5658 let sink = ProgressSink::new(recorder.clone());
5659
5660 let path = reg
5661 .ensure_local_with_progress("mlx/reuse-complete", &sink)
5662 .await
5663 .unwrap();
5664
5665 assert_eq!(path, models.join("Reuse-Complete-MLX"));
5666 assert!(recorder.events().is_empty());
5667 }
5668
5669 #[tokio::test]
5670 async fn reusing_a_managed_mlx_dir_without_tokenizer_json_emits_no_lifecycle() {
5671 let _environment = crate::openrouter::test_environment_scope_async().await;
5672 let tmp = TempDir::new().unwrap();
5673 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5674 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5675 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5676 let models = tmp.path().join("models");
5677 let mut reg = UnifiedRegistry::new_empty(models.clone());
5678 reg.register(mlx_schema(
5679 "mlx/reuse-no-tokenizer",
5680 "Reuse-No-Tokenizer-MLX",
5681 "example/reuse-no-tokenizer",
5682 ));
5683 write_mlx_dir(&models, "Reuse-No-Tokenizer-MLX", "qwen3");
5684 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5685 let sink = ProgressSink::new(recorder.clone());
5686
5687 let path = reg
5688 .ensure_local_with_progress("mlx/reuse-no-tokenizer", &sink)
5689 .await
5690 .unwrap();
5691
5692 assert_eq!(path, models.join("Reuse-No-Tokenizer-MLX"));
5693 assert!(recorder.events().is_empty());
5694 }
5695
5696 #[tokio::test]
5697 async fn reusing_a_complete_hf_snapshot_emits_no_acquisition_lifecycle() {
5698 let _environment = crate::openrouter::test_environment_scope_async().await;
5699 let tmp = TempDir::new().unwrap();
5700 let hf_home = tmp.path().join("hf-home");
5701 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
5702 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5703 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5704 let models = tmp.path().join("models");
5705 let repo = "example/reuse-hf-snapshot";
5706 let snapshot = hf_home
5707 .join("hub")
5708 .join("models--example--reuse-hf-snapshot")
5709 .join("snapshots")
5710 .join("fixture");
5711 write_complete_mlx_snapshot(&snapshot);
5712 let mut reg = UnifiedRegistry::new_empty(models);
5713 reg.register(mlx_schema("mlx/reuse-hf", "Reuse-HF-MLX", repo));
5714 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5715 let sink = ProgressSink::new(recorder.clone());
5716
5717 let path = reg
5718 .ensure_local_with_progress("mlx/reuse-hf", &sink)
5719 .await
5720 .unwrap();
5721
5722 assert_eq!(path, snapshot);
5723 assert!(recorder.events().is_empty());
5724 }
5725
5726 #[tokio::test]
5727 async fn a_zero_byte_managed_config_json_starts_acquisition() {
5728 let _environment = crate::openrouter::test_environment_scope_async().await;
5729 let tmp = TempDir::new().unwrap();
5730 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5731 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5732 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5733 let models = tmp.path().join("models");
5734 let name = "Zero-Byte-Config-MLX";
5735 write_mlx_dir(&models, name, "qwen3");
5736 let model_dir = models.join(name);
5737 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
5738 std::fs::write(model_dir.join("config.json"), b"").unwrap();
5739 let mut reg = UnifiedRegistry::new_empty(models);
5740 reg.register(mlx_schema(
5741 "mlx/zero-byte-config",
5742 name,
5743 "example/zero-byte-config",
5744 ));
5745 let recorder =
5746 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
5747 let sink = ProgressSink::new(recorder.clone());
5748
5749 let result = reg
5750 .ensure_local_with_progress("mlx/zero-byte-config", &sink)
5751 .await;
5752
5753 let events = recorder.events();
5754 assert!(
5755 matches!(events.first(), Some(DownloadEvent::Started { .. })),
5756 "weights beside a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
5757 );
5758 assert_eq!(started_count(&events), 1);
5759 assert!(result.is_err());
5760 }
5761
5762 #[tokio::test]
5763 async fn a_zero_byte_hf_snapshot_config_json_starts_acquisition() {
5764 let _environment = crate::openrouter::test_environment_scope_async().await;
5765 let tmp = TempDir::new().unwrap();
5766 let hf_home = tmp.path().join("hf-home");
5767 let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
5768 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5769 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5770 let models = tmp.path().join("models");
5771 let name = "Zero-Byte-Snapshot-Config-MLX";
5772 let repo = "example/zero-byte-snapshot-config";
5773 let snapshot = hf_home
5774 .join("hub")
5775 .join("models--example--zero-byte-snapshot-config")
5776 .join("snapshots")
5777 .join("fixture");
5778 write_complete_mlx_snapshot(&snapshot);
5779 std::fs::write(snapshot.join("config.json"), b"").unwrap();
5780 let model_dir = models.join(name);
5783 std::fs::create_dir_all(&model_dir).unwrap();
5784 let mut reg = UnifiedRegistry::new_empty(models);
5785 reg.register(mlx_schema("mlx/zero-byte-snapshot-config", name, repo));
5786 let recorder =
5787 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
5788 let sink = ProgressSink::new(recorder.clone());
5789
5790 let result = reg
5791 .ensure_local_with_progress("mlx/zero-byte-snapshot-config", &sink)
5792 .await;
5793
5794 let events = recorder.events();
5795 assert!(
5796 matches!(events.first(), Some(DownloadEvent::Started { .. })),
5797 "a snapshot with a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
5798 );
5799 assert_eq!(started_count(&events), 1);
5800 assert!(result.is_err());
5801 }
5802
5803 #[cfg(unix)]
5804 #[tokio::test]
5805 async fn an_oversized_installed_model_is_reused_below_the_disk_threshold() {
5806 let _environment = crate::openrouter::test_environment_scope_async().await;
5807 let tmp = TempDir::new().unwrap();
5808 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5809 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5810 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5811 let models = tmp.path().join("models");
5812 let mut schema = mlx_schema(
5813 "mlx/reuse-oversized",
5814 "Reuse-Oversized-MLX",
5815 "example/reuse-oversized",
5816 );
5817 schema.cost.size_mb = Some(1_000_000_000);
5820 let mut reg = UnifiedRegistry::new_empty(models.clone());
5821 reg.register(schema);
5822 write_mlx_dir(&models, "Reuse-Oversized-MLX", "qwen3");
5823 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5824 let sink = ProgressSink::new(recorder.clone());
5825
5826 let path = reg
5827 .ensure_local_with_progress("mlx/reuse-oversized", &sink)
5828 .await
5829 .unwrap();
5830
5831 assert_eq!(path, models.join("Reuse-Oversized-MLX"));
5832 assert!(recorder.events().is_empty());
5833 }
5834
5835 #[tokio::test]
5836 async fn a_missing_shard_starts_acquisition_exactly_once_after_the_preflight() {
5837 let _environment = crate::openrouter::test_environment_scope_async().await;
5838 let tmp = TempDir::new().unwrap();
5839 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5840 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5841 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5842 let models = tmp.path().join("models");
5843 let model_dir = models.join("Missing-Shard-MLX");
5844 std::fs::create_dir_all(&model_dir).unwrap();
5845 std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
5846 std::fs::write(
5847 model_dir.join("model.safetensors.index.json"),
5848 r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
5849 )
5850 .unwrap();
5851 std::fs::write(model_dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
5852 let mut reg = UnifiedRegistry::new_empty(models);
5853 reg.register(mlx_schema(
5854 "mlx/missing-shard-lifecycle",
5855 "Missing-Shard-MLX",
5856 "example/missing-shard",
5857 ));
5858 let recorder =
5859 std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
5860 let sink = ProgressSink::new(recorder.clone());
5861
5862 assert!(reg
5863 .ensure_local_with_progress("mlx/missing-shard-lifecycle", &sink)
5864 .await
5865 .is_err());
5866 let events = recorder.events();
5867 assert!(matches!(
5868 events.first(),
5869 Some(DownloadEvent::Started { .. })
5870 ));
5871 assert_eq!(started_count(&events), 1);
5872 }
5873
5874 #[tokio::test]
5875 async fn a_flux_dir_missing_its_auxiliary_tokenizer_keeps_the_lifecycle() {
5876 let _environment = crate::openrouter::test_environment_scope_async().await;
5877 let tmp = TempDir::new().unwrap();
5878 let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5879 let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5880 let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5881 let models = tmp.path().join("models");
5882 let name = "Flux-1.lite-8B-MLX-Q4";
5883 write_mlx_dir(&models, name, "flux");
5884 std::fs::write(models.join(name).join("tokenizer_2"), b"not a directory").unwrap();
5885 let mut reg = UnifiedRegistry::new_empty(models);
5886 reg.register(mlx_schema(
5887 "mlx/flux-missing-aux",
5888 name,
5889 "mlx-community/Flux-1.lite-8B-MLX-Q4",
5890 ));
5891 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5892 let sink = ProgressSink::new(recorder.clone());
5893
5894 assert!(reg
5895 .ensure_local_with_progress("mlx/flux-missing-aux", &sink)
5896 .await
5897 .is_err());
5898 let events = recorder.events();
5899 assert!(matches!(
5900 events.first(),
5901 Some(DownloadEvent::Started { .. })
5902 ));
5903 assert_eq!(started_count(&events), 1);
5904 }
5905
5906 #[tokio::test]
5907 async fn force_bypasses_reuse() {
5908 let _environment = crate::openrouter::test_environment_scope_async().await;
5909 let tmp = TempDir::new().unwrap();
5910 let models = tmp.path().join("models");
5911 let name = "Force-Local";
5912 let model_dir = models.join(name);
5913 std::fs::create_dir_all(&model_dir).unwrap();
5914 std::fs::write(model_dir.join("model.gguf"), b"weights").unwrap();
5915 std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
5916 let mut reg = UnifiedRegistry::new_empty(models);
5917 reg.register(test_generate_schema(
5918 "local/force-lifecycle",
5919 name,
5920 ModelSource::Local {
5921 hf_repo: String::new(),
5922 hf_filename: "model.gguf".into(),
5923 tokenizer_repo: String::new(),
5924 },
5925 ));
5926 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5927 let sink = ProgressSink::new(recorder.clone());
5928
5929 assert!(reg
5930 .acquire_and_ensure("local/force-lifecycle", &sink, true, None)
5931 .await
5932 .is_err());
5933 let events = recorder.events();
5934 assert!(matches!(
5935 events.first(),
5936 Some(DownloadEvent::Started { .. })
5937 ));
5938 assert_eq!(started_count(&events), 1);
5939 }
5940
5941 #[tokio::test]
5942 async fn staged_pulls_keep_the_lifecycle() {
5943 let _environment = crate::openrouter::test_environment_scope_async().await;
5944 let tmp = TempDir::new().unwrap();
5945 let models = tmp.path().join("models");
5946 let canonical = models.join("Staged-Local");
5947 std::fs::create_dir_all(&canonical).unwrap();
5948 std::fs::write(canonical.join("model.gguf"), b"canonical weights").unwrap();
5949 std::fs::write(canonical.join("tokenizer.json"), b"{}").unwrap();
5950 let mut reg = UnifiedRegistry::new_empty(models);
5951 reg.register(test_generate_schema(
5952 "local/staged-lifecycle",
5953 "Staged-Local",
5954 ModelSource::Local {
5955 hf_repo: String::new(),
5956 hf_filename: "model.gguf".into(),
5957 tokenizer_repo: String::new(),
5958 },
5959 ));
5960 let staging = tmp.path().join("staging");
5961 std::fs::create_dir_all(&staging).unwrap();
5962 std::fs::write(staging.join("model.gguf"), b"weights").unwrap();
5963 std::fs::write(staging.join("tokenizer.json"), b"{}").unwrap();
5964 let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5965 let sink = ProgressSink::new(recorder.clone());
5966
5967 let path = reg
5968 .acquire_and_ensure("local/staged-lifecycle", &sink, false, Some(&staging))
5969 .await
5970 .unwrap();
5971 assert_eq!(path, staging);
5972 let events = recorder.events();
5973 assert!(matches!(
5974 events.first(),
5975 Some(DownloadEvent::Started { .. })
5976 ));
5977 assert_eq!(started_count(&events), 1);
5978 }
5979
5980 #[test]
5981 fn synthesize_local_schema_classifies_by_name_and_arch() {
5982 let tmp = TempDir::new().unwrap();
5983 let root = tmp.path();
5984
5985 write_mlx_dir(root, "MyCustom-Qwen3-7B", "qwen3");
5986 let gen = synthesize_local_schema("MyCustom-Qwen3-7B", &root.join("MyCustom-Qwen3-7B"))
5987 .expect("text LLM should be recognized");
5988 assert_eq!(
5989 gen.capabilities,
5990 vec![
5991 ModelCapability::Generate,
5992 ModelCapability::Code,
5993 ModelCapability::Reasoning
5994 ]
5995 );
5996 assert_eq!(gen.context_length, 40_960);
5997 assert_eq!(gen.provider, "local");
5998 assert!(matches!(gen.source, ModelSource::Mlx { .. }));
5999
6000 write_mlx_dir(root, "Some-Embedding-0.6B", "qwen3");
6001 let emb = synthesize_local_schema("Some-Embedding-0.6B", &root.join("Some-Embedding-0.6B"))
6002 .expect("embedding model recognized");
6003 assert_eq!(emb.capabilities, vec![ModelCapability::Embed]);
6004
6005 write_mlx_dir(root, "Mystery-Net", "some_unknown_arch");
6007 assert!(synthesize_local_schema("Mystery-Net", &root.join("Mystery-Net")).is_none());
6008
6009 write_mlx_dir(root, "silero-vad-v6-mlx", "qwen3");
6011 assert!(
6012 synthesize_local_schema("silero-vad-v6-mlx", &root.join("silero-vad-v6-mlx")).is_none()
6013 );
6014
6015 std::fs::create_dir_all(root.join("empty")).unwrap();
6017 assert!(synthesize_local_schema("empty", &root.join("empty")).is_none());
6018 }
6019
6020 #[test]
6026 fn a_scanned_gguf_directory_diagnoses_instead_of_downloading_from_nowhere() {
6027 let _environment = crate::openrouter::test_environment_scope();
6028 let tmp = TempDir::new().unwrap();
6029 let models = tmp.path().join("models");
6030 let dir = models.join("Dropped-In-Llama");
6031 std::fs::create_dir_all(&dir).unwrap();
6032 std::fs::write(dir.join("Llama-3-8B-Q4_K_M.gguf"), b"weights").unwrap();
6034
6035 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6038 let err = tokio::runtime::Runtime::new()
6039 .unwrap()
6040 .block_on(reg.ensure_local("Dropped-In-Llama"))
6041 .expect_err("an unloadable layout must not report success");
6042 let err = err.to_string();
6043
6044 assert!(err.contains("model.gguf"), "must name what it reads: {err}");
6045 assert!(
6046 err.contains("tokenizer.json"),
6047 "must name the missing tokenizer too: {err}"
6048 );
6049 assert!(
6050 !err.contains("huggingface.co//"),
6051 "must not have tried to fetch from an empty repo: {err}"
6052 );
6053 }
6054
6055 #[test]
6056 fn discovery_registers_uncatalogued_local_model() {
6057 let _environment = crate::openrouter::test_environment_scope();
6062 let tmp = TempDir::new().unwrap();
6063 let models = tmp.path().join("models");
6064 std::fs::create_dir_all(&models).unwrap();
6065 write_mlx_dir(&models, "Totally-Custom-Llama-3B", "llama");
6066
6067 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6068 let found = reg
6069 .list()
6070 .into_iter()
6071 .find(|m| m.name == "Totally-Custom-Llama-3B");
6072 assert!(
6073 found.is_some(),
6074 "uncatalogued on-disk model should be registered"
6075 );
6076 assert!(found.unwrap().tags.iter().any(|t| t == "auto-discovered"));
6077 }
6078
6079 #[test]
6080 fn explicit_user_row_is_not_pruned_by_auto_discovery_tag() {
6081 let _environment = crate::openrouter::test_environment_scope();
6082 let tmp = TempDir::new().unwrap();
6083 let models = tmp.path().join("models");
6084 let model_dir = models.join("Explicit-Custom-Llama");
6085 write_mlx_dir(&models, "Explicit-Custom-Llama", "llama");
6086
6087 let mut registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6088 let model_id = "local/explicit-custom-llama";
6089 let schema = registry.get(model_id).cloned().unwrap();
6090 assert!(schema.tags.iter().any(|tag| tag == "auto-discovered"));
6091
6092 registry.register_user_model(schema);
6093 std::fs::remove_dir_all(model_dir).unwrap();
6094 registry.prune_missing_on_disk_models();
6095
6096 assert!(
6097 registry.get(model_id).is_some(),
6098 "explicit registration provenance must outrank a user-controlled tag"
6099 );
6100 }
6101
6102 #[test]
6107 fn colliding_directory_does_not_claim_a_registered_row() {
6108 let tmp = TempDir::new().unwrap();
6109 let models = tmp.path().join("models");
6110 write_mlx_dir(&models, "Collide-User-Llama", "llama");
6111 write_mlx_dir(&models, "Collide-Project-Llama", "llama");
6112 let mut registry = UnifiedRegistry::new_empty(models.clone());
6113
6114 let mut user =
6115 synthesize_local_schema("Collide-User-Llama", &models.join("Collide-User-Llama"))
6116 .unwrap();
6117 user.name = "Configured-Elsewhere-User".into();
6118 let user_id = user.id.clone();
6119 registry.register_user_model(user);
6120
6121 let mut project = synthesize_local_schema(
6122 "Collide-Project-Llama",
6123 &models.join("Collide-Project-Llama"),
6124 )
6125 .unwrap();
6126 project.name = "Configured-Elsewhere-Project".into();
6127 let project_id = project.id.clone();
6128 assert!(registry.register_project_model(project));
6129
6130 registry.discover_on_disk_models();
6131 assert_eq!(
6132 registry.get(&user_id).map(|m| m.name.as_str()),
6133 Some("Configured-Elsewhere-User"),
6134 "discovery must not replace a registered row"
6135 );
6136
6137 std::fs::remove_dir_all(models.join("Collide-User-Llama")).unwrap();
6138 std::fs::remove_dir_all(models.join("Collide-Project-Llama")).unwrap();
6139 registry.prune_missing_on_disk_models();
6140
6141 assert!(
6142 registry.get(&user_id).is_some(),
6143 "removing a colliding directory must not erase a user row"
6144 );
6145 assert!(
6146 registry.get(&project_id).is_some(),
6147 "removing a colliding directory must not erase a project row"
6148 );
6149 }
6150
6151 #[test]
6152 fn signed_catalog_cannot_shadow_builtin_exact_id() {
6153 let _environment = crate::openrouter::test_environment_scope();
6158 let tmp = TempDir::new().unwrap();
6162 let models_dir = tmp.path().join("models");
6163
6164 let builtin = builtin_catalog();
6165 let mut overriding = builtin.first().expect("a built-in model").clone();
6166 let target_id = overriding.id.clone();
6167 overriding.name = "REPLACED-BY-CATALOG".into();
6168
6169 let (verified, public_key) = crate::catalog::signed_test_catalog(
6170 crate::catalog::CatalogDoc {
6171 revoked: Vec::new(),
6172 version: 1,
6173 models: vec![overriding],
6174 },
6175 51,
6176 );
6177 crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
6178
6179 let reg = UnifiedRegistry::new_with_catalog_public_key(
6181 tmp.path().to_path_buf(),
6182 models_dir,
6183 Some(public_key.as_str()),
6184 );
6185 assert_eq!(
6186 reg.get(&target_id).map(|m| m.name.as_str()),
6187 Some(builtin.first().unwrap().name.as_str()),
6188 "a signed cache row must not replace a builtin exact id"
6189 );
6190 }
6191
6192 #[test]
6193 fn user_model_cannot_shadow_project_owned_exact_id() {
6194 let mut reg = test_registry();
6195 let original = builtin_catalog().first().expect("a builtin").clone();
6196 let mut forged = original.clone();
6197 forged.name = "USER-SHADOW".into();
6198
6199 reg.register_user_model(forged);
6200
6201 assert_eq!(
6202 reg.get(&original.id).map(|model| model.name.as_str()),
6203 Some(original.name.as_str()),
6204 "a user row must not replace a project-owned exact id"
6205 );
6206 }
6207
6208 #[test]
6209 fn legacy_unsigned_catalog_cache_cannot_replace_builtin() {
6210 let _environment = crate::openrouter::test_environment_scope();
6215 let tmp = TempDir::new().unwrap();
6216 let models_dir = tmp.path().join("models");
6217 let builtin = builtin_catalog();
6218 let original = builtin.first().expect("a built-in model");
6219 let mut forged = original.clone();
6220 forged.name = "FORGED-UNSIGNED-CATALOG".into();
6221 let path = crate::catalog::cache_path(tmp.path());
6222 std::fs::write(
6223 &path,
6224 serde_json::to_vec_pretty(&crate::catalog::CatalogDoc {
6225 revoked: Vec::new(),
6226 version: u64::MAX,
6227 models: vec![forged],
6228 })
6229 .unwrap(),
6230 )
6231 .unwrap();
6232
6233 let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
6234 assert_eq!(
6235 reg.get(&original.id).map(|model| model.name.as_str()),
6236 Some(original.name.as_str()),
6237 "legacy unsigned cache JSON must fail closed and preserve the built-in"
6238 );
6239 }
6240
6241 #[test]
6242 fn tampered_signed_managed_row_preserves_builtin() {
6243 let _environment = crate::openrouter::test_environment_scope();
6248 let tmp = TempDir::new().unwrap();
6249 let models_dir = tmp.path().join("models");
6250 let original = builtin_catalog()
6251 .into_iter()
6252 .find(|model| model.id == "parslee/openrouter/frontier-general")
6253 .expect("managed frontier alias");
6254 let mut forged = original.clone();
6255 forged.name = "SIGNED-THEN-TAMPERED-MANAGED".into();
6256 let (verified, public_key) = crate::catalog::signed_test_catalog(
6257 crate::catalog::CatalogDoc {
6258 revoked: Vec::new(),
6259 version: 9,
6260 models: vec![forged],
6261 },
6262 52,
6263 );
6264 let path = crate::catalog::cache_path(tmp.path());
6265 crate::catalog::save_verified(&path, &verified).unwrap();
6266 let cache = std::fs::read_to_string(&path)
6267 .unwrap()
6268 .replace("SIGNED-THEN-TAMPERED-MANAGED", "ATTACKER-MUTATION");
6269 std::fs::write(&path, cache).unwrap();
6270
6271 let reg = UnifiedRegistry::new_with_catalog_public_key(
6272 tmp.path().to_path_buf(),
6273 models_dir,
6274 Some(public_key.as_str()),
6275 );
6276 assert_eq!(
6277 reg.get(&original.id).map(|model| model.name.as_str()),
6278 Some(original.name.as_str()),
6279 "a tampered same-id managed row must fail verification and preserve the builtin"
6280 );
6281 }
6282
6283 #[test]
6284 fn builtin_catalog_loads() {
6285 let reg = test_registry();
6286 let all = reg.list();
6287 assert_eq!(all.len(), builtin_catalog().len());
6288 }
6289
6290 #[test]
6291 fn subscription_billing_lookup_uses_exact_or_implicit_latest_ids() {
6292 let codex_ids: Vec<_> = builtin_catalog()
6293 .into_iter()
6294 .filter(|model| model.id.starts_with("openai-codex/"))
6295 .map(|model| model.id)
6296 .collect();
6297 assert!(!codex_ids.is_empty());
6298 for id in codex_ids {
6299 assert!(is_builtin_subscription_billed(&id), "{id}");
6300 if let Some(untagged) = id.strip_suffix(":latest") {
6301 assert!(is_builtin_subscription_billed(untagged), "{untagged}");
6302 }
6303 }
6304 assert!(!is_builtin_subscription_billed("parslee/reasoning"));
6305 assert!(!is_builtin_subscription_billed("openai/gpt-5.5-2026-04-23"));
6306 assert!(!is_builtin_subscription_billed("unknown/not-a-model"));
6307 }
6308
6309 #[test]
6310 fn shipped_supervised_vllm_models_use_the_managed_source_contract() {
6311 let managed = builtin_catalog()
6312 .into_iter()
6313 .filter(|model| model.id.starts_with("vllm-mlx/"))
6314 .collect::<Vec<_>>();
6315 assert_eq!(managed.len(), 8);
6316 assert!(managed.iter().all(ModelSchema::is_car_managed_vllm_mlx));
6317 assert!(managed.iter().all(ModelSchema::downloads_weights));
6318 }
6319
6320 #[test]
6333 fn mlx_vlm_models_reflect_runtime_availability() {
6334 let reg = test_registry();
6335 let mlx_vlm_models: Vec<&ModelSchema> = reg
6336 .list()
6337 .into_iter()
6338 .filter(|m| m.tags.iter().any(|t| t == "requires-mlx-vlm"))
6339 .collect();
6340 assert!(
6341 !mlx_vlm_models.is_empty(),
6342 "catalog should contain at least one model tagged \
6343 `requires-mlx-vlm` — otherwise this regression has \
6344 nothing to guard"
6345 );
6346
6347 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6348 let expected = crate::backend::mlx_vlm_cli::is_available();
6349 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6350 let expected = false;
6351
6352 for m in mlx_vlm_models {
6353 assert_eq!(
6354 m.available, expected,
6355 "model {} `available` field should reflect \
6356 mlx_vlm CLI presence (expected {expected}, got {})",
6357 m.id, m.available
6358 );
6359 }
6360 }
6361
6362 #[test]
6374 fn mlx_models_unavailable_on_non_mlx_targets() {
6375 let reg = test_registry();
6376 let mlx_models: Vec<&ModelSchema> = reg
6377 .list()
6378 .into_iter()
6379 .filter(|m| {
6380 m.is_mlx()
6381 && !m.tags.iter().any(|t| t == "requires-mlx-vlm")
6386 && !m.tags.contains(&"speech".to_string())
6387 })
6388 .collect();
6389 assert!(
6390 !mlx_models.is_empty(),
6391 "catalog should contain at least one plain MLX model — \
6392 otherwise this F1 regression guard has nothing to guard"
6393 );
6394
6395 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6396 {
6397 let any_available = mlx_models.iter().any(|m| m.available);
6401 assert!(
6402 any_available,
6403 "on macOS arm64 with MLX enabled, at least one plain MLX \
6404 model with hf_repo should be available — none were"
6405 );
6406 }
6407 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6408 {
6409 for m in &mlx_models {
6412 assert!(
6413 !m.available,
6414 "MLX model {} is marked available on a non-MLX target — \
6415 the adaptive router will add it to fallback chains \
6416 and dispatch will fail (Parslee-ai/car#231 §7.1)",
6417 m.id
6418 );
6419 }
6420 }
6421 }
6422
6423 fn is_builtin_model_id_form(id: &str) -> bool {
6428 fn part(s: &str) -> bool {
6429 let mut chars = s.chars();
6430 chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
6431 && chars.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
6432 }
6433 let (body, tag) = match id.split_once(':') {
6434 Some((body, tag)) => (body, Some(tag)),
6435 None => (id, None),
6436 };
6437 let Some((provider, name)) = body.split_once('/') else {
6438 return false;
6439 };
6440 part(provider) && part(name) && tag.is_none_or(part)
6441 }
6442
6443 #[test]
6444 fn builtin_model_ids_follow_the_resolved_model_id_contract() {
6445 for good in [
6446 "anthropic/claude-opus-4-8",
6447 "openai/gpt-5.5-2026-04-23",
6448 "qwen/qwen3-4b:q4_k_m",
6449 ] {
6450 assert!(is_builtin_model_id_form(good), "{good} should pass");
6451 }
6452 for bad in [
6453 "",
6454 "gpt-5.5",
6455 "/name",
6456 "openai/",
6457 "-openai/x",
6458 "openai/-x",
6459 "open ai/x",
6460 "openai/x y",
6461 "openai/é",
6462 "parslee/openrouter/open-fast",
6463 "openai/x:",
6464 "openai/x:-a",
6465 "openai/x:a b",
6466 "openai/x:a:b",
6467 "openai/x:a/b",
6468 ] {
6469 assert!(!is_builtin_model_id_form(bad), "{bad:?} should fail");
6470 }
6471 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6472 let off: Vec<&str> = catalog
6473 .iter()
6474 .map(|m| m.id.as_str())
6475 .filter(|id| !is_builtin_model_id_form(id))
6476 .collect();
6477 assert!(off.is_empty(), "built-in ids outside the contract: {off:?}");
6478 }
6479
6480 #[test]
6481 fn builtin_catalog_json_parses() {
6482 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON)
6483 .expect("builtin_catalog.json must be valid ModelSchema array");
6484 assert!(
6485 !catalog.is_empty(),
6486 "embedded catalog has no entries — that's almost certainly wrong"
6487 );
6488
6489 let mut seen = std::collections::HashSet::new();
6490 for entry in &catalog {
6491 assert!(
6492 seen.insert(entry.id.clone()),
6493 "duplicate id in builtin_catalog.json: {}",
6494 entry.id
6495 );
6496 }
6497 }
6498
6499 #[test]
6500 fn codex_subscription_row_has_pinnable_tool_proposal_identity() {
6501 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6502 let row = catalog
6503 .iter()
6504 .find(|model| model.id == "openai/gpt-5.6-sol:high")
6505 .expect("catalog publishes the Codex subscription identity");
6506 assert_eq!(row.name, "gpt-5.6-sol:high");
6507 assert!(matches!(
6508 &row.source,
6509 ModelSource::CodexCli { model } if model == "gpt-5.6-sol:high"
6510 ));
6511 assert!(row.has_capability(ModelCapability::Generate));
6512 assert!(row.has_capability(ModelCapability::Reasoning));
6513 assert!(row.has_capability(ModelCapability::ToolUse));
6514 assert!(row.has_capability(ModelCapability::MultiToolCall));
6515 assert!(row.has_capability(ModelCapability::Vision));
6517 assert!(!row.tags.iter().any(|tag| tag == "text-only"));
6518 assert_eq!(row.supported_params, vec![GenerateParam::MaxTokens]);
6519 assert!(!row.downloads_weights());
6520 }
6521
6522 #[test]
6523 fn codex_subscription_rows_publish_the_gpt_5_6_family() {
6524 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6525 assert_eq!(
6526 catalog
6527 .iter()
6528 .filter(|model| model.provider == "openai-codex")
6529 .count(),
6530 6,
6531 "the explicit checks below must cover every openai-codex row"
6532 );
6533 for id in [
6534 "openai-codex/gpt-5.6-sol:latest",
6535 "openai-codex/gpt-5.6-sol:high",
6536 "openai-codex/gpt-5.6-luna:latest",
6537 "openai-codex/gpt-5.6-luna:high",
6538 "openai-codex/gpt-5.6-terra:latest",
6539 "openai-codex/gpt-5.6-terra:high",
6540 ] {
6541 let row = catalog
6542 .iter()
6543 .find(|model| model.id == id)
6544 .unwrap_or_else(|| panic!("missing {id}"));
6545 assert_eq!(row.provider, "openai-codex", "{id}");
6546 assert!(
6547 matches!(&row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::ChatGptSubscription {}, .. } if provider == "openai-codex"),
6548 "{id}"
6549 );
6550 assert!(row.is_subscription_billed(), "{id}");
6551 assert!(
6552 row.cost.input_per_mtok.is_none() && row.cost.output_per_mtok.is_none(),
6553 "{id} must carry no dollar price"
6554 );
6555 assert!(row.tags.iter().any(|tag| tag == "subscription"), "{id}");
6556 assert!(
6557 row.cost
6558 .estimated_usd_bounded(Some(1_000), 1_000, 1_000, 0, 0)
6559 .is_none(),
6560 "{id} must not produce an approximate dollar cost"
6561 );
6562 let mut accidentally_priced = row.clone();
6563 accidentally_priced.cost.input_per_mtok = Some(1.0);
6564 accidentally_priced.cost.output_per_mtok = Some(1.0);
6565 assert!(
6566 crate::scoreboard_price_model(&accidentally_priced).is_none(),
6567 "{id} must stay out of the dollar scoreboard"
6568 );
6569 assert!(row.has_capability(ModelCapability::ToolUse), "{id}");
6570 assert!(row.has_capability(ModelCapability::Vision), "{id}");
6572 assert_eq!(row.name.ends_with(":high"), id.ends_with(":high"), "{id}");
6573 }
6574 let cli = catalog
6575 .iter()
6576 .find(|model| model.id == "openai/gpt-5.6-sol:high")
6577 .unwrap();
6578 assert!(
6579 matches!(&cli.source, ModelSource::CodexCli { .. }),
6580 "the Codex CLI row stays as it was"
6581 );
6582 assert!(!cli.is_subscription_billed());
6583 let api = catalog
6584 .iter()
6585 .find(|model| model.id == "openai/gpt-5.6-sol:latest")
6586 .unwrap();
6587 assert!(
6588 matches!(&api.source, ModelSource::RemoteApi { .. }),
6589 "the API-key row stays as it was"
6590 );
6591 assert!(!api.is_subscription_billed());
6592 let parslee = catalog
6593 .iter()
6594 .find(|model| model.id == "parslee/fast")
6595 .expect("catalog publishes a Parslee row");
6596 assert!(!parslee.is_subscription_billed());
6597 }
6598
6599 #[test]
6600 fn codex_rows_unavailable_without_sign_in_carry_the_sign_in_reason() {
6601 let _environment = crate::openrouter::test_environment_scope();
6602 remember_codex_sign_in(false);
6603
6604 let tmp = TempDir::new().unwrap();
6605 let mut registry = UnifiedRegistry::new_with_state_root(
6606 tmp.path().to_path_buf(),
6607 tmp.path().join("models"),
6608 );
6609 let expected_ids = [
6610 "openai-codex/gpt-5.6-sol:latest",
6611 "openai-codex/gpt-5.6-sol:high",
6612 "openai-codex/gpt-5.6-luna:latest",
6613 "openai-codex/gpt-5.6-luna:high",
6614 "openai-codex/gpt-5.6-terra:latest",
6615 "openai-codex/gpt-5.6-terra:high",
6616 ];
6617
6618 for id in expected_ids {
6619 let info = ModelInfo::from(
6620 registry
6621 .get(id)
6622 .unwrap_or_else(|| panic!("missing builtin Codex row {id}")),
6623 );
6624 assert!(!info.available, "{id} must be unavailable while signed out");
6625 let reason = model_unavailable_reason(&info).expect("signed-out Codex reason");
6626 assert_eq!(reason, crate::schema::OPENAI_CODEX_SIGN_IN_HINT);
6627 assert_eq!(
6628 serde_json::to_value(&info).unwrap()["unavailable_reason"],
6629 reason
6630 );
6631 assert!(reason.contains("car auth login --provider openai-codex"));
6632 assert!(reason.contains("Settings → AI accounts"));
6633 }
6634
6635 remember_codex_sign_in(true);
6636 registry.refresh_availability();
6637
6638 for id in expected_ids {
6639 let info = ModelInfo::from(registry.get(id).unwrap());
6640 assert!(info.available, "{id} must be available while signed in");
6641 assert_eq!(model_unavailable_reason(&info), None, "{id}");
6642 assert!(serde_json::to_value(&info).unwrap()["unavailable_reason"].is_null());
6643 }
6644
6645 remember_codex_sign_in(false);
6646 }
6647
6648 #[test]
6649 fn work_context_availability_is_independent_of_other_org_rejection() {
6650 let _environment = crate::openrouter::test_environment_scope();
6651 crate::parslee_credential::clear_credential_rejected();
6652 let tmp = TempDir::new().unwrap();
6653 let mut parslee = UnifiedRegistry::new_with_state_root(
6654 tmp.path().to_path_buf(),
6655 tmp.path().join("models"),
6656 );
6657 let mut flyexclusive = parslee.clone();
6658 crate::parslee_credential::note_credential_rejected();
6659 parslee.refresh_work_context_availability("https://authority.example", true);
6660 flyexclusive.refresh_work_context_availability("https://authority.example", false);
6661 let scoped_rows: Vec<_> = parslee.all().filter(|row| matches!(
6662 &row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::OAuth2Pkce { .. }, .. }
6663 if provider == "parslee"
6664 )).collect();
6665 assert!(!scoped_rows.is_empty());
6666 for row in scoped_rows {
6667 assert!(row.available);
6668 assert!(!flyexclusive.get(&row.id).unwrap().available);
6669 }
6670 assert!(
6671 crate::parslee_credential::credential_rejected(),
6672 "scoped routing must not clear another context's observation"
6673 );
6674 crate::parslee_credential::clear_credential_rejected();
6675 }
6676
6677 #[test]
6678 fn codex_availability_leaves_openai_and_parslee_rows_alone() {
6679 let _environment = crate::openrouter::test_environment_scope();
6680 remember_codex_sign_in(false);
6681
6682 let tmp = TempDir::new().unwrap();
6683 let mut registry = UnifiedRegistry::new_with_state_root(
6684 tmp.path().to_path_buf(),
6685 tmp.path().join("models"),
6686 );
6687 registry.refresh_availability();
6688
6689 let unaffected_ids: Vec<String> = registry
6690 .list()
6691 .into_iter()
6692 .filter(|row| {
6693 matches!(
6694 row.id.as_str(),
6695 "openai/gpt-5.6-sol:latest" | "openai/gpt-5.6-sol:high"
6696 ) || row.id.starts_with("parslee/")
6697 })
6698 .map(|row| row.id.clone())
6699 .collect();
6700 assert!(unaffected_ids
6701 .iter()
6702 .any(|id| id == "openai/gpt-5.6-sol:latest"));
6703 assert!(unaffected_ids
6704 .iter()
6705 .any(|id| id == "openai/gpt-5.6-sol:high"));
6706 assert!(
6707 unaffected_ids.iter().any(|id| id.starts_with("parslee/")),
6708 "builtin catalog must contain Parslee rows"
6709 );
6710 let signed_out: std::collections::HashMap<String, bool> = unaffected_ids
6711 .iter()
6712 .map(|id| (id.clone(), registry.get(id).unwrap().available))
6713 .collect();
6714
6715 remember_codex_sign_in(true);
6716 registry.refresh_availability();
6717
6718 for id in unaffected_ids {
6719 assert_eq!(
6720 registry.get(&id).unwrap().available,
6721 signed_out[&id],
6722 "Codex sign-in must not change {id} availability"
6723 );
6724 }
6725
6726 remember_codex_sign_in(false);
6727 }
6728
6729 #[test]
6730 fn builtin_catalog_names_are_unique_for_codex_rows() {
6731 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6732 for row in catalog.iter().filter(|row| row.provider == "openai-codex") {
6733 let occurrences = catalog
6734 .iter()
6735 .filter(|candidate| candidate.name == row.name)
6736 .count();
6737 assert_eq!(
6738 occurrences, 1,
6739 "openai-codex name must be unique: {}",
6740 row.name
6741 );
6742 }
6743 }
6744
6745 #[test]
6768 fn apple_foundation_row_claims_single_tool_call_and_the_real_context_window() {
6769 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6770 let row = catalog
6771 .iter()
6772 .find(|model| model.id == "apple/foundation:default")
6773 .expect("catalog publishes the Apple FoundationModels row");
6774 assert!(matches!(
6775 &row.source,
6776 ModelSource::AppleFoundationModels { .. }
6777 ));
6778 assert!(row.has_capability(ModelCapability::Generate));
6779 assert!(
6780 row.has_capability(ModelCapability::ToolUse),
6781 "generate_with_tools exists, so the router must be able to pick this row for tool routes"
6782 );
6783 assert!(
6784 !row.has_capability(ModelCapability::MultiToolCall),
6785 "the capture sentinel ends the turn on the first tool call"
6786 );
6787 assert!(!row.has_capability(ModelCapability::Vision));
6788 assert_eq!(
6789 row.context_length, 4096,
6790 "SystemLanguageModel's context window is 4096 tokens"
6791 );
6792 assert!(!row.downloads_weights());
6793 }
6794
6795 #[test]
6808 fn apple_foundation_multi_tool_call_tracks_what_the_host_can_actually_do() {
6809 #[cfg(any(
6810 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6811 all(target_os = "ios", target_arch = "aarch64")
6812 ))]
6813 {
6814 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6815 let declared = catalog
6816 .iter()
6817 .find(|m| m.id == "apple/foundation:default")
6818 .expect("catalog publishes the Apple FoundationModels row");
6819 assert!(
6820 !declared.has_capability(ModelCapability::MultiToolCall),
6821 "the catalog literal must stay conservative; the host decides"
6822 );
6823
6824 let models_dir = tempfile::tempdir().unwrap();
6826 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
6827 let live = registry
6833 .models
6834 .get("apple/foundation:default")
6835 .expect("the builtin catalog row must survive into the live registry");
6836 let host_can = crate::backend::foundation_models::supports_parallel_tool_calls();
6837 assert_eq!(
6838 live.capabilities.contains(&ModelCapability::MultiToolCall),
6839 host_can,
6840 "live row must claim multi_tool_call iff the host supports it"
6841 );
6842 }
6843 }
6844
6845 #[test]
6857 fn apple_foundation_claims_vision_only_where_images_can_be_served() {
6858 #[cfg(any(
6859 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6860 all(target_os = "ios", target_arch = "aarch64")
6861 ))]
6862 {
6863 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6864 let declared = catalog
6865 .iter()
6866 .find(|m| m.id == "apple/foundation:default")
6867 .expect("catalog publishes the Apple FoundationModels row");
6868 assert!(
6869 !declared.has_capability(ModelCapability::Vision),
6870 "the catalog literal must stay conservative; the device decides"
6871 );
6872
6873 let models_dir = tempfile::tempdir().unwrap();
6874 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
6875 let live = registry
6876 .models
6877 .get("apple/foundation:default")
6878 .expect("the builtin catalog row must survive into the live registry");
6879
6880 assert_eq!(
6881 live.capabilities.contains(&ModelCapability::Vision),
6882 crate::backend::foundation_models::supports_vision(),
6883 "the live row must claim vision iff this device's model accepts images"
6884 );
6885 }
6886 }
6887
6888 #[test]
6905 fn a_host_without_parallel_support_gets_exactly_the_catalog_capabilities() {
6906 #[cfg(any(
6907 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6908 all(target_os = "ios", target_arch = "aarch64")
6909 ))]
6910 {
6911 if crate::backend::foundation_models::supports_parallel_tool_calls() {
6912 return;
6913 }
6914 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6915 let declared = catalog
6916 .iter()
6917 .find(|m| m.id == "apple/foundation:default")
6918 .expect("catalog publishes the Apple FoundationModels row");
6919
6920 let models_dir = tempfile::tempdir().unwrap();
6921 let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
6922 let live = registry
6923 .models
6924 .get("apple/foundation:default")
6925 .expect("the builtin catalog row must survive into the live registry");
6926
6927 assert_eq!(
6928 live.capabilities, declared.capabilities,
6929 "a host without the macOS 27 behaviour must advertise exactly what \
6930 the catalog declares — nothing added, nothing removed"
6931 );
6932 }
6933 }
6934
6935 #[test]
6945 fn apple_foundation_context_size_is_either_the_os_answer_or_none() {
6946 #[cfg(any(
6947 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6948 all(target_os = "ios", target_arch = "aarch64")
6949 ))]
6950 {
6951 if let Some(window) = crate::backend::foundation_models::context_size() {
6952 assert!(
6953 window >= 512,
6954 "a context window the framework reports must be usable, got {window}"
6955 );
6956 }
6957 }
6958
6959 #[cfg(not(any(
6962 all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6963 all(target_os = "ios", target_arch = "aarch64")
6964 )))]
6965 {
6966 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6967 let row = catalog
6968 .iter()
6969 .find(|model| model.id == "apple/foundation:default")
6970 .expect("catalog publishes the Apple FoundationModels row");
6971 assert_eq!(row.context_length, 4096);
6972 }
6973 }
6974
6975 #[test]
6982 fn in_process_qwen3_models_declare_tool_use() {
6983 use crate::schema::ModelSource;
6984 let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6985 let tool_sizes = ["qwen3-4b", "qwen3-8b", "qwen3-30b-a3b"];
6988 let mut checked = 0;
6989 for entry in &catalog {
6990 let in_process = matches!(
6991 entry.source,
6992 ModelSource::Mlx { .. } | ModelSource::Local { .. }
6993 );
6994 if !in_process || !tool_sizes.iter().any(|s| entry.id.contains(s)) {
6995 continue;
6996 }
6997 assert!(
6998 entry.capabilities.contains(&ModelCapability::ToolUse),
6999 "in-process Qwen3 model {} should advertise ToolUse — the local \
7000 generate path renders/parses tool calls",
7001 entry.id
7002 );
7003 checked += 1;
7004 }
7005 assert_eq!(
7006 checked, 6,
7007 "expected 6 in-process tool-capable Qwen3 entries (3 mlx + 3 gguf)"
7008 );
7009 }
7010
7011 #[test]
7012 fn public_benchmarks_round_trip_through_model_info() {
7013 use crate::schema::BenchmarkScore;
7014 let mut reg = test_registry();
7015 let mut schema = reg
7016 .find_by_name("Qwen3-4B")
7017 .expect("catalog has Qwen3-4B")
7018 .clone();
7019 schema.id = "test/qwen3-4b-with-bench".into();
7020 schema.public_benchmarks = vec![
7021 BenchmarkScore {
7022 name: "MMLU-Pro".into(),
7023 score: 0.482,
7024 harness: Some("5-shot CoT".into()),
7025 source_url: Some("https://example.invalid/qwen3-4b-card".into()),
7026 measured_at: Some("2025-08-12".into()),
7027 runs: None,
7028 spread: None,
7029 },
7030 BenchmarkScore {
7031 name: "HumanEval".into(),
7032 score: 0.713,
7033 harness: Some("pass@1".into()),
7034 source_url: None,
7035 measured_at: None,
7036 runs: None,
7037 spread: None,
7038 },
7039 ];
7040 reg.register(schema);
7041
7042 let stored = reg
7043 .get("test/qwen3-4b-with-bench")
7044 .expect("registered model is retrievable");
7045 let info = ModelInfo::from(stored);
7046 assert_eq!(info.public_benchmarks.len(), 2);
7047
7048 let json = serde_json::to_string(&info).unwrap();
7050 assert!(json.contains("\"public_benchmarks\""));
7051 assert!(json.contains("\"MMLU-Pro\""));
7052 assert!(json.contains("\"5-shot CoT\""));
7053
7054 let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
7056 assert_eq!(decoded.public_benchmarks.len(), 2);
7057 assert_eq!(decoded.public_benchmarks[0].name, "MMLU-Pro");
7058 assert_eq!(decoded.public_benchmarks[1].name, "HumanEval");
7059 }
7060
7061 #[test]
7062 fn public_benchmarks_default_to_empty_when_absent_in_json() {
7063 let legacy_json = r#"{
7066 "id": "legacy/test:1",
7067 "name": "Legacy Test",
7068 "provider": "test",
7069 "family": "test",
7070 "version": "",
7071 "capabilities": ["generate"],
7072 "context_length": 4096,
7073 "param_count": "1B",
7074 "quantization": null,
7075 "performance": {},
7076 "cost": {},
7077 "source": { "type": "ollama", "model_tag": "legacy:1" },
7078 "tags": [],
7079 "supported_params": []
7080 }"#;
7081 let schema: ModelSchema = serde_json::from_str(legacy_json).unwrap();
7082 assert!(schema.public_benchmarks.is_empty());
7083 }
7084
7085 #[test]
7086 fn find_by_name() {
7087 let reg = test_registry();
7088 let m = reg.find_by_name("Qwen3-4B").unwrap();
7089 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7090 assert_eq!(m.id, "mlx/qwen3-4b:4bit");
7091 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7092 assert_eq!(m.id, "qwen/qwen3-4b:q4_k_m");
7093 assert!(m.has_capability(ModelCapability::Code));
7094 }
7095
7096 #[test]
7097 fn query_by_capability() {
7098 let reg = test_registry();
7099 let embed_models = reg.query_by_capability(ModelCapability::Embed);
7100 assert_eq!(embed_models.len(), 2);
7101 assert!(embed_models
7102 .iter()
7103 .any(|model| model.name == "Qwen3-Embedding-0.6B"));
7104 assert!(embed_models
7105 .iter()
7106 .any(|model| model.name == "Qwen3-Embedding-0.6B-MLX"));
7107 }
7108
7109 #[test]
7110 fn query_with_filter() {
7111 let reg = test_registry();
7112 let code_small = reg.query(&ModelFilter {
7113 capabilities: vec![ModelCapability::Code],
7114 max_size_mb: Some(3000),
7115 local_only: true,
7116 ..Default::default()
7117 });
7118 assert_eq!(code_small.len(), 4);
7120 }
7121
7122 #[test]
7123 fn register_remote() {
7124 let mut reg = test_registry();
7125 let initial_len = reg.list().len();
7126 let initial_reasoning_len = reg
7127 .query(&ModelFilter {
7128 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
7129 ..Default::default()
7130 })
7131 .len();
7132 let remote = ModelSchema {
7133 id: "anthropic/claude-sonnet-4-6:latest".into(),
7134 name: "Claude Sonnet 4.6".into(),
7135 provider: "anthropic".into(),
7136 family: "claude-4".into(),
7137 version: "latest".into(),
7138 capabilities: vec![
7139 ModelCapability::Generate,
7140 ModelCapability::Code,
7141 ModelCapability::Reasoning,
7142 ModelCapability::ToolUse,
7143 ],
7144 context_length: 200000,
7145 max_output_tokens: None,
7146 param_count: String::new(),
7147 quantization: None,
7148 performance: PerformanceEnvelope {
7149 latency_p50_ms: Some(2000),
7150 ..Default::default()
7151 },
7152 cost: CostModel {
7153 input_per_mtok: Some(3.0),
7154 output_per_mtok: Some(15.0),
7155 ..Default::default()
7156 },
7157 source: ModelSource::RemoteApi {
7158 endpoint: "https://api.anthropic.com/v1/messages".into(),
7159 api_key_env: "ANTHROPIC_API_KEY".into(),
7160 api_key_envs: vec![],
7161 api_version: Some("2023-06-01".into()),
7162 protocol: ApiProtocol::Anthropic,
7163 },
7164 tags: vec![],
7165 supported_params: vec![],
7166 public_benchmarks: vec![],
7167 trust_tier: crate::schema::TrustTier::Curated,
7168 deprecated: false,
7169 available: false,
7170 weights_ready: false,
7171 };
7172
7173 reg.register(remote);
7174 assert_eq!(reg.list().len(), initial_len);
7176
7177 let reasoning = reg.query(&ModelFilter {
7178 capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
7179 ..Default::default()
7180 });
7181 assert_eq!(reasoning.len(), initial_reasoning_len);
7183 }
7184
7185 #[test]
7186 fn unregister() {
7187 let mut reg = test_registry();
7188 let initial_len = reg.list().len();
7189 let removed = reg.unregister("qwen/qwen3-0.6b:q8_0");
7190 assert!(removed.is_some());
7191 assert_eq!(reg.list().len(), initial_len - 1);
7192 }
7193
7194 #[test]
7195 fn speech_models_are_curated() {
7196 let reg = test_registry();
7197 let stt = reg.query_by_capability(ModelCapability::SpeechToText);
7198 let tts = reg.query_by_capability(ModelCapability::TextToSpeech);
7199 assert_eq!(stt.len(), 3);
7201 assert_eq!(tts.len(), 5);
7203 let whisper = stt
7206 .iter()
7207 .find(|m| m.name == "Whisper-large-v3-turbo-q5_0")
7208 .expect("whisper STT model should be curated");
7209 assert!(whisper.is_local());
7210 assert!(matches!(
7211 whisper.source,
7212 crate::schema::ModelSource::WhisperCpp { .. }
7213 ));
7214 }
7215
7216 #[test]
7217 fn qwen_8b_variants_keep_tool_use_consistent() {
7218 let reg = test_registry();
7223 for name in ["Qwen3-8B", "Qwen3-8B-MLX"] {
7224 let model = reg.find_by_name(name).expect("model should exist");
7225 assert!(model.has_capability(ModelCapability::ToolUse));
7226 assert!(model.has_capability(ModelCapability::MultiToolCall));
7227 }
7228 }
7229
7230 #[test]
7231 fn mac_name_resolution_prefers_mlx_siblings() {
7232 #[allow(unused_variables)]
7235 let reg = test_registry();
7236 #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7237 {
7238 assert_eq!(
7239 reg.find_by_name("Qwen3-0.6B").unwrap().id,
7240 "mlx/qwen3-0.6b:6bit"
7241 );
7242 assert_eq!(
7243 reg.find_by_name("Qwen3-1.7B").unwrap().id,
7244 "mlx/qwen3-1.7b:3bit"
7245 );
7246 assert_eq!(
7247 reg.find_by_name("Qwen3-Embedding-0.6B").unwrap().id,
7248 "mlx/qwen3-embedding-0.6b:mxfp8"
7249 );
7250 }
7251 }
7252
7253 #[test]
7254 fn remote_multimodal_models_are_curated_as_vision_capable() {
7255 let reg = test_registry();
7256 for name in [
7257 "claude-opus-4-7",
7258 "claude-opus-4-6",
7259 "claude-sonnet-4-6",
7260 "claude-haiku-4-5",
7261 "gpt-5.4",
7262 "gpt-5.4-mini",
7263 "o3",
7264 "o4-mini",
7265 "gpt-4.1-mini",
7266 "gemini-2.5-pro",
7267 "gemini-2.5-flash",
7268 ] {
7269 let model = reg.find_by_name(name).expect("model should exist");
7270 assert!(
7271 model.has_capability(ModelCapability::Vision),
7272 "{name} should be curated as vision-capable"
7273 );
7274 }
7275 }
7276
7277 #[test]
7278 fn qwen25vl_entries_are_replaced_by_qwen3vl_in_builtin_catalog() {
7279 let reg = test_registry();
7280
7281 let stale_ids = [
7282 "mlx/qwen2.5-vl-3b:4bit",
7284 "mlx/qwen2.5-vl-7b:4bit",
7285 "mlx-vlm/qwen2.5-vl-3b:4bit",
7288 "mlx-vlm/qwen2.5-vl-7b:4bit",
7289 "vllm-mlx/qwen2.5-vl-3b:4bit",
7291 ];
7292 for id in stale_ids {
7293 assert!(
7294 reg.get(id).is_none(),
7295 "{id} is superseded by Qwen3-VL; the catalog must not advertise it"
7296 );
7297 }
7298
7299 let vision_ids: Vec<&str> = reg
7300 .query_by_capability(ModelCapability::Vision)
7301 .into_iter()
7302 .map(|model| model.id.as_str())
7303 .collect();
7304 for stale in stale_ids {
7305 assert!(
7306 !vision_ids.contains(&stale),
7307 "{stale} must not be reachable through the Vision capability index"
7308 );
7309 }
7310 assert!(
7311 vision_ids.contains(&"mlx-vlm/qwen3-vl-2b:bf16"),
7312 "Qwen3-VL is the supported local VL family and must route as Vision"
7313 );
7314 }
7315
7316 #[test]
7317 fn gemini_models_are_curated_for_multimodal_tool_use() {
7318 let reg = test_registry();
7319 for name in ["gemini-2.5-pro", "gemini-2.5-flash"] {
7320 let model = reg.find_by_name(name).expect("model should exist");
7321 assert!(model.has_capability(ModelCapability::Vision));
7322 assert!(model.has_capability(ModelCapability::ToolUse));
7323 assert!(model.has_capability(ModelCapability::MultiToolCall));
7324 }
7325 }
7326
7327 #[test]
7328 fn model_info_publishes_declared_prices_and_keeps_unpriced_distinct_from_free() {
7329 let reg = test_registry();
7330
7331 let opus = reg
7333 .list()
7334 .into_iter()
7335 .find(|m| m.id == "openrouter/anthropic/claude-opus-4.8")
7336 .map(ModelInfo::from)
7337 .expect("curated opus-4.8 row is present on first boot");
7338 assert_eq!(opus.cost.input_per_mtok, Some(5.0));
7339 assert_eq!(opus.cost.output_per_mtok, Some(25.0));
7340 assert_eq!(opus.cost.cache_read_input_per_mtok, Some(0.5));
7341 assert_eq!(opus.cost.cache_write_input_per_mtok, Some(6.25));
7342
7343 let gpt = reg
7345 .list()
7346 .into_iter()
7347 .find(|m| m.id == "openrouter/openai/gpt-5.4")
7348 .map(ModelInfo::from)
7349 .expect("curated gpt-5.4 row");
7350 assert_eq!(gpt.cost.pricing_tiers.len(), 1);
7351 assert_eq!(gpt.cost.prices_for(272_000).input_per_mtok, Some(5.0));
7352
7353 let local = reg
7356 .list()
7357 .into_iter()
7358 .find(|m| m.is_local() && m.cost.input_per_mtok.is_none())
7359 .map(ModelInfo::from)
7360 .expect("the built-in catalog ships unpriced local models");
7361 let json = serde_json::to_value(&local).unwrap();
7362 assert!(json["cost"]["input_per_mtok"].is_null());
7363 assert!(json["cost"]["output_per_mtok"].is_null());
7364 assert_ne!(json["cost"]["input_per_mtok"], serde_json::json!(0.0));
7365 }
7366
7367 #[test]
7368 fn a_hand_registered_copy_of_a_curated_id_does_not_double_the_row() {
7369 let mut reg = test_registry();
7370 let id = "openrouter/anthropic/claude-opus-4.8";
7371 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
7372
7373 let mut copy = reg
7376 .list()
7377 .into_iter()
7378 .find(|m| m.id == id)
7379 .cloned()
7380 .expect("curated row");
7381 copy.name = "hand-registered".into();
7382 reg.register_user_model(copy);
7383 assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
7384 }
7385
7386 #[test]
7394 fn managed_alias_publishes_prices_without_disclosing_the_upstream_id_in_the_catalog_view() {
7395 let reg = test_registry();
7396 let alias = reg
7397 .list()
7398 .into_iter()
7399 .find(|m| m.id == "parslee/openrouter/frontier-deep-next")
7400 .map(ModelInfo::from)
7401 .expect("managed alias for the new curated row");
7402
7403 assert_eq!(alias.cost.input_per_mtok, Some(5.0));
7404 assert_eq!(alias.cost.output_per_mtok, Some(25.0));
7405 assert_eq!(alias.cost.cache_read_input_per_mtok, Some(0.5));
7406 assert_eq!(alias.cost.cache_write_input_per_mtok, Some(6.25));
7407
7408 let wire = serde_json::to_string(&alias).unwrap();
7409 assert!(!wire.contains("claude-opus-4.8"));
7410 assert!(!wire.contains("anthropic/"));
7411 }
7412
7413 #[test]
7414 fn model_info_from_an_older_daemon_without_cost_still_parses() {
7415 let legacy = serde_json::json!({
7418 "id": "legacy/model",
7419 "name": "legacy",
7420 "provider": "legacy",
7421 "capabilities": ["generate"],
7422 "param_count": "",
7423 "size_mb": 0,
7424 "context_length": 8192,
7425 "available": true,
7426 "is_local": false
7427 });
7428 let info: ModelInfo = serde_json::from_value(legacy).expect("older catalog row parses");
7429 assert!(info.cost.input_per_mtok.is_none());
7430 assert!(info.cost.output_per_mtok.is_none());
7431 assert!(info.cost.pricing_tiers.is_empty());
7432 assert!(info.max_output_tokens.is_none());
7433 assert!(info.car_enabled, "legacy rows default to enabled");
7434 assert!(!info.can_remove);
7435 assert!(!info.in_use);
7436 assert!(info.management_evidence.is_none());
7437 }
7438
7439 #[test]
7440 fn visual_generation_models_are_curated() {
7441 let reg = test_registry();
7442 assert_eq!(
7443 reg.query_by_capability(ModelCapability::ImageGeneration)
7444 .len(),
7445 1
7446 );
7447 assert_eq!(
7448 reg.query_by_capability(ModelCapability::VideoGeneration)
7449 .len(),
7450 1
7451 );
7452 }
7453}
7454
7455#[cfg(test)]
7464mod builtin_catalog_validation {
7465 use super::*;
7466 use crate::schema::ModelSource;
7467
7468 fn weight_repo(source: &ModelSource) -> Option<&str> {
7470 match source {
7471 ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
7472 ModelSource::Local { hf_repo, .. } => Some(hf_repo),
7473 ModelSource::ManagedVllmMlx { hf_repo, .. } => Some(hf_repo),
7474 _ => None,
7475 }
7476 }
7477
7478 #[test]
7479 fn ids_are_unique() {
7480 let catalog = builtin_catalog();
7481 let mut seen: Vec<&str> = Vec::new();
7482 for model in &catalog {
7483 assert!(
7484 !seen.contains(&model.id.as_str()),
7485 "duplicate catalog id `{}` — the later entry silently shadows the earlier",
7486 model.id
7487 );
7488 seen.push(&model.id);
7489 }
7490 }
7491
7492 #[test]
7493 fn source_kind_is_the_serialized_source_tag() {
7494 for model in builtin_catalog() {
7495 let tagged = serde_json::to_value(&model.source).unwrap();
7496 assert_eq!(
7497 tagged["type"].as_str(),
7498 Some(model.source.kind()),
7499 "{}",
7500 model.id
7501 );
7502 assert_eq!(
7503 crate::ModelInfo::from(&model).source_kind,
7504 model.source.kind()
7505 );
7506 }
7507 let codex = builtin_catalog()
7508 .into_iter()
7509 .find(|model| model.id == "openai/gpt-5.6-sol:high")
7510 .unwrap();
7511 let api = builtin_catalog()
7512 .into_iter()
7513 .find(|model| model.id == "openai/gpt-5.6-sol:latest")
7514 .unwrap();
7515 assert_eq!(crate::ModelInfo::from(&codex).source_kind, "codex_cli");
7516 assert_eq!(crate::ModelInfo::from(&api).source_kind, "remote_api");
7517 }
7518
7519 #[test]
7520 fn exact_frontier_rows_lock_native_selectors_and_digests() {
7521 let catalog = builtin_catalog();
7522 for (id, name, version, expected_digest) in [
7523 (
7524 "openai/gpt-5.5-2026-04-23",
7525 "gpt-5.5-2026-04-23",
7526 "2026-04-23",
7527 "7eeaecc6e83ce409b37cc68e9c44ba84d64c5f9690179a494bdcb25a93f0aac3",
7528 ),
7529 (
7530 "anthropic/claude-opus-4-8",
7531 "claude-opus-4-8",
7532 "4.8",
7533 "10504959e51dc76c3563df91ae2eaba57cf814834ecd657232c50f264f9e735e",
7534 ),
7535 (
7536 "openai/gpt-5.6-sol:high",
7537 "gpt-5.6-sol:high",
7538 "latest",
7539 "6b552a2bd00193915c624ce98d2ae7c9ccd73e037e735760ab903105413d587b",
7540 ),
7541 ] {
7542 let row = catalog
7543 .iter()
7544 .find(|model| model.id == id)
7545 .unwrap_or_else(|| panic!("missing exact production row {id}"));
7546 assert_eq!(row.name, name);
7547 assert_eq!(row.version, version);
7548 assert_eq!(
7549 crate::catalog_identity::row_digest(row).unwrap(),
7550 expected_digest,
7551 "CAR row digest drifted for {id}"
7552 );
7553 }
7554 }
7555
7556 #[test]
7557 fn weight_repos_are_well_formed_huggingface_ids() {
7558 for model in builtin_catalog() {
7559 let Some(repo) = weight_repo(&model.source) else {
7560 continue;
7561 };
7562 assert_eq!(
7563 repo.split('/').count(),
7564 2,
7565 "{}: `{repo}` is not an `org/name` HuggingFace id",
7566 model.id
7567 );
7568 assert!(
7569 !repo.split('/').any(str::is_empty),
7570 "{}: `{repo}` has an empty path segment",
7571 model.id
7572 );
7573 assert!(
7574 !repo.contains(char::is_whitespace),
7575 "{}: `{repo}` contains whitespace",
7576 model.id
7577 );
7578 }
7579 }
7580
7581 #[test]
7585 fn param_counts_are_parseable_or_deliberately_empty() {
7586 for model in builtin_catalog() {
7587 if weight_repo(&model.source).is_none() || model.param_count.is_empty() {
7588 continue;
7589 }
7590 assert!(
7591 model.param_count.starts_with(|c: char| c.is_ascii_digit()),
7592 "{}: param_count `{}` does not start with a number, so the quality \
7593 prior cannot read it — leave it empty rather than descriptive",
7594 model.id,
7595 model.param_count
7596 );
7597 }
7598 }
7599
7600 #[test]
7603 fn catalog_vllm_mlx_entries_use_explicit_managed_ownership() {
7604 for model in builtin_catalog() {
7605 if !model.is_vllm_mlx() {
7606 continue;
7607 }
7608 assert!(
7609 model.is_car_managed_vllm_mlx(),
7610 "{}: a CAR-supervised catalog row must opt into ManagedVllmMlx; \
7611 loopback alone cannot confer ownership",
7612 model.id
7613 );
7614 }
7615 }
7616
7617 #[test]
7618 fn generate_capable_models_declare_a_context_window() {
7619 for model in builtin_catalog() {
7620 if !model.has_capability(crate::schema::ModelCapability::Generate) {
7621 continue;
7622 }
7623 assert!(
7624 model.context_length > 0,
7625 "{}: a generate-capable model with no context_length breaks budget sizing",
7626 model.id
7627 );
7628 }
7629 }
7630
7631 #[test]
7632 fn every_entry_declares_at_least_one_capability() {
7633 for model in builtin_catalog() {
7634 assert!(
7635 !model.capabilities.is_empty(),
7636 "{}: an entry with no capabilities can never be routed to",
7637 model.id
7638 );
7639 }
7640 }
7641}
7642
7643#[cfg(test)]
7644mod gguf_quantization_tests {
7645 use crate::schema::{QuantScheme, Quantization};
7646
7647 fn quantization_from_gguf_filename(name: &str) -> Option<Quantization> {
7648 Quantization::from_gguf_filename(name)
7649 }
7650
7651 #[test]
7652 fn reads_the_quantization_a_gguf_file_names() {
7653 let cases = [
7654 ("Qwen3-8B-Q4_K_M.gguf", "Q4_K_M", QuantScheme::KQuantMixed),
7655 (
7656 "Qwen3-Embedding-0.6B-Q8_0.gguf",
7657 "Q8_0",
7658 QuantScheme::RtnBlock,
7659 ),
7660 (
7661 "ggml-large-v3-turbo-q5_0.gguf",
7662 "q5_0",
7663 QuantScheme::RtnBlock,
7664 ),
7665 ("model-IQ4_XS.gguf", "IQ4_XS", QuantScheme::KQuantMixed),
7666 ];
7667 for (filename, label, scheme) in cases {
7668 let q = quantization_from_gguf_filename(filename)
7669 .unwrap_or_else(|| panic!("no quantization found in {filename}"));
7670 assert_eq!(q.label, label, "label for {filename}");
7671 assert_eq!(q.scheme, scheme, "scheme for {filename}");
7672 }
7673 }
7674
7675 #[test]
7677 fn returns_none_when_the_name_says_nothing() {
7678 for filename in ["model.gguf", "llama-2-7b-chat.gguf", "ggml-base.gguf"] {
7679 assert!(
7680 quantization_from_gguf_filename(filename).is_none(),
7681 "should not have guessed from {filename}"
7682 );
7683 }
7684 }
7685
7686 #[test]
7689 fn the_rightmost_match_wins() {
7690 let q = quantization_from_gguf_filename("q8-experiment-Q4_K_M.gguf").unwrap();
7691 assert_eq!(q.label, "Q4_K_M");
7692 }
7693}
7694
7695#[cfg(test)]
7696mod mlx_executor_discrimination_tests {
7697 use super::*;
7698
7699 #[test]
7711 fn only_in_process_text_rows_need_the_swift_loader() {
7712 let catalog = builtin_catalog();
7713 let row = |id: &str| {
7714 catalog
7715 .iter()
7716 .find(|m| m.id == id)
7717 .unwrap_or_else(|| panic!("{id} missing from the builtin catalog"))
7718 };
7719
7720 for id in [
7721 "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",
7726 ] {
7727 let m = row(id);
7728 assert!(
7729 matches!(m.source, ModelSource::Mlx { .. }),
7730 "{id}: precondition — this test is only meaningful for Mlx-sourced rows"
7731 );
7732 assert!(
7733 !mlx_row_needs_in_process_loader(m),
7734 "{id} runs in a subprocess; its availability must not depend on the \
7735 in-process Swift loader"
7736 );
7737 }
7738
7739 for id in ["mlx/qwen3-4b:4bit", "mlx/gemma-4-12b-it:4bit"] {
7742 assert!(
7743 mlx_row_needs_in_process_loader(row(id)),
7744 "{id} is served in-process and does need the loader"
7745 );
7746 }
7747 }
7748}
7749
7750#[cfg(test)]
7751mod local_availability_tests {
7752 use super::*;
7753 use crate::schema::ModelSchema;
7754 use tempfile::TempDir;
7755
7756 fn gguf_row(id: &str, hf_repo: &str) -> ModelSchema {
7757 let mut schema: ModelSchema = serde_json::from_value(serde_json::json!({
7758 "id": id,
7759 "name": id.replace('/', "-"),
7760 "provider": "qwen",
7761 "family": "qwen3",
7762 "capabilities": ["generate"],
7763 "context_length": 32768,
7764 "param_count": "8B",
7765 "source": {
7766 "type": "local",
7767 "hf_repo": hf_repo,
7768 "hf_filename": "model.gguf",
7769 "tokenizer_repo": hf_repo,
7770 },
7771 "cost": { "size_mb": 4900 },
7772 }))
7773 .unwrap();
7774 schema.available = false;
7775 schema
7776 }
7777
7778 #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7789 #[test]
7790 fn a_declared_repo_is_available_before_it_is_downloaded() {
7791 let _environment = crate::openrouter::test_environment_scope();
7792 let tmp = TempDir::new().unwrap();
7793 let models = tmp.path().join("models");
7794 std::fs::create_dir_all(&models).unwrap();
7795
7796 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
7797 reg.register(gguf_row("qwen/test-8b:q4_k_m", "Qwen/Qwen3-8B-GGUF"));
7798
7799 let model = reg.get("qwen/test-8b:q4_k_m").expect("registered");
7800 assert!(
7801 model.available,
7802 "a GGUF model with a repo to fetch from must not report unavailable \
7803 just because nothing has downloaded it yet"
7804 );
7805 }
7806
7807 #[test]
7811 fn a_row_with_nowhere_to_fetch_from_stays_unavailable() {
7812 let _environment = crate::openrouter::test_environment_scope();
7813 let tmp = TempDir::new().unwrap();
7814 let models = tmp.path().join("models");
7815 std::fs::create_dir_all(&models).unwrap();
7816
7817 let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
7818 reg.register(gguf_row("local/scanned", ""));
7819
7820 let model = reg.get("local/scanned").expect("registered");
7821 assert!(
7822 !model.available,
7823 "an empty hf_repo has no download to promise"
7824 );
7825 }
7826
7827 #[test]
7828 fn builtin_credential_names_cover_remote_and_proprietary_auth() {
7829 let names = builtin_credential_env_names();
7830 for name in [
7831 "OPENAI_API_KEY",
7832 "ANTHROPIC_API_KEY",
7833 crate::openrouter::API_KEY_ENV,
7834 ] {
7835 assert!(names.contains(name), "built-in catalog omitted {name}");
7836 }
7837 }
7838}