Skip to main content

car_inference/
registry.rs

1//! Unified model registry — local and remote models under one schema.
2//!
3//! Replaces the hardcoded `ModelRegistry` from `models.rs` with a schema-driven
4//! registry that treats all models as first-class typed resources. Users can
5//! register custom models (fine-tuned endpoints, private APIs) alongside the
6//! built-in catalog.
7
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::time::SystemTime;
11
12use serde::{Deserialize, Serialize};
13use tracing::{info, warn};
14
15use crate::download::{DownloadEvent, ProgressSink};
16use crate::schema::*;
17use crate::InferenceError;
18
19/// Filter for querying the registry.
20#[derive(Debug, Clone, Default)]
21pub struct ModelFilter {
22    /// Required capabilities (model must have ALL of these).
23    pub capabilities: Vec<ModelCapability>,
24    /// Maximum on-disk / RAM size in MB.
25    pub max_size_mb: Option<u64>,
26    /// Maximum expected latency in ms (from declared envelope).
27    pub max_latency_ms: Option<u64>,
28    /// Maximum cost per 1M output tokens in USD.
29    pub max_cost_per_mtok: Option<f64>,
30    /// Required tags (model must have ALL of these).
31    pub tags: Vec<String>,
32    /// Filter by provider.
33    pub provider: Option<String>,
34    /// Only local models.
35    pub local_only: bool,
36    /// Only models that are currently available.
37    pub available_only: bool,
38}
39
40/// A curated replacement for a local model that is installed but no longer
41/// the preferred model in its line.
42#[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/// How a registry answers "is a Parslee session signed in?", and whether it may
64/// act on a "no" by discarding session-scoped evidence.
65///
66/// Both call sites used to read `car_auth::access_token_is_available()`
67/// directly, which quietly made *constructing* a registry a process-global
68/// write: `new_with_catalog_public_key` ends in a `refresh_availability`, and on
69/// a machine with no session that refresh clears the learned gateway and
70/// credential observations. #989 took the crate's serial lock in the two tests
71/// that call `refresh_availability` themselves, but roughly twenty others merely
72/// build a registry and reached the same clear through construction — so a test
73/// that had just recorded an observation could have it erased under it
74/// (Parslee-ai/car#986). This probe is the seam that separates *what the answer
75/// is* from *whether acting on it is allowed*.
76#[derive(Debug, Clone, Copy)]
77pub(crate) enum SessionProbe {
78    /// Production. Ask `car_auth` on every refresh and ACT on the answer — a
79    /// signed-out answer discards the session-scoped gateway/credential
80    /// evidence (Parslee-ai/car#786, Parslee-ai/car#887).
81    // Never constructed in a test build — `Inert` is the `cfg(test)` default,
82    // which is the whole point of the seam rather than dead code.
83    #[cfg_attr(test, allow(dead_code))]
84    Live,
85    /// A caller-supplied answer, acted on exactly as `Live` acts on it:
86    /// `Fixed(false)` discards, `Fixed(true)` does not. A test that means to
87    /// exercise the discard asks for it here rather than depending on the
88    /// ambient auth state of the machine it runs on.
89    #[cfg(test)]
90    Fixed(bool),
91    /// The real answer, asked exactly as `Live` asks it, and never acted on.
92    /// Availability is therefore identical to `Live`, and the only difference
93    /// is that nothing outside the registry is written. Used by read-only
94    /// diagnostics and as the default under `cfg(test)`.
95    Inert,
96}
97
98impl SessionProbe {
99    /// Whether a Parslee session is signed in, using only the prompt-free
100    /// authority hint. Registry construction and catalog refresh must not turn
101    /// into a physical Keychain read.
102    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    /// Whether the authority is explicitly signed out rather than merely
112    /// unknown or temporarily unreadable.
113    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    /// Whether a "not signed in" answer may discard the session-scoped
129    /// gateway/credential observations. False only for [`Self::Inert`], which
130    /// exists precisely so that building a registry has no effect outside it.
131    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/// Unified registry of all known models.
142#[derive(Clone)]
143pub struct UnifiedRegistry {
144    models_dir: PathBuf,
145    /// CAR state root — `~/.car`, or `$CAR_HOME` when the operator relocated
146    /// this daemon. Every *state* path the registry touches (`models.json`, the
147    /// signed-catalog cache, the discovery cache) hangs off this, never off
148    /// `models_dir`, which is the machine-shared weights cache and deliberately
149    /// does not move.
150    state_root: PathBuf,
151    /// All registered models, keyed by id.
152    models: HashMap<String, ModelSchema>,
153    /// Exact ids owned by the compiled or authenticated project catalog.
154    /// Public/user-controlled rows are additive and may never replace these.
155    project_model_ids: HashSet<String>,
156    /// The strongest reserved namespace: exact ids compiled into this build.
157    /// Even an authenticated refresh is additive and cannot change them.
158    builtin_model_ids: HashSet<String>,
159    /// IDs whose current rows came from the user-controlled `models.json`
160    /// boundary or an explicit user registration.
161    ///
162    /// Tags cannot carry this provenance: a user can add `builtin`, while a
163    /// valid signed catalog row does not have to. Keeping the boundary private
164    /// prevents generic/builtin/catalog/discovery registration from being
165    /// serialized back into user config and losing its trust tier on restart.
166    user_config_ids: HashSet<String>,
167    /// User-added model config file path — `models.json` resolved against
168    /// `state_root`, i.e. the same answer the free [`user_config_path`] gives.
169    user_config_path: PathBuf,
170    /// Where to report downloads that nobody explicitly asked for.
171    ///
172    /// [`ensure_local`](Self::ensure_local) is the *implicit* acquisition path:
173    /// it runs inside `generate`, when the router picks a model whose weights
174    /// aren't on disk yet. It used to hard-code `ProgressSink::none()`, so a
175    /// multi-gigabyte fetch triggered by a plain `car infer "hi"` produced no
176    /// output on any surface — the command simply appeared to hang, and a
177    /// user on a metered connection had no signal at all (Parslee-ai/car#620).
178    ///
179    /// Explicit pulls (`car models pull`) always passed a real sink and were
180    /// never affected; this closes the gap for the path users actually hit
181    /// first. Defaults to [`ProgressSink::none`], so an embedder that doesn't
182    /// opt in behaves exactly as before.
183    ambient_progress: ProgressSink,
184    /// How this registry learns whether a Parslee session is signed in, and
185    /// whether it may act on a "no". See [`SessionProbe`]: normal production
186    /// registries carry `Live`; read-only diagnostics and test registries use
187    /// `Inert` so construction cannot erase an existing observation.
188    session: SessionProbe,
189}
190
191#[derive(Debug, Clone, Deserialize)]
192struct ModelUpgradeRule {
193    from_ids: Vec<String>,
194    to_id: String,
195    reason: String,
196    target_runtime: Option<String>,
197    target_runtime_requirement: Option<String>,
198    #[serde(default)]
199    minimum_runtimes: Vec<ModelRuntimeRequirement>,
200    #[serde(default = "default_remove_old_after_available")]
201    remove_old_after_available: bool,
202}
203
204fn default_remove_old_after_available() -> bool {
205    true
206}
207
208fn environment_credential_available(env_var: &str) -> bool {
209    std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
210}
211
212fn passive_parslee_oauth_available() -> bool {
213    matches!(
214        car_auth::credential_authority_hint().state,
215        car_auth::CredentialAuthorityState::Configured
216    )
217}
218
219/// `resolved` is the per-refresh credential memo. Passive catalog refreshes
220/// populate it from environment presence only; request-time refreshes may
221/// populate it authoritatively from the secret store. Keeping the source of
222/// truth outside this helper prevents a single-model registration from
223/// silently becoming a physical Keychain read.
224fn proprietary_auth_available(
225    model_id: &str,
226    schema_provider: &str,
227    source_provider: &str,
228    auth: &ProprietaryAuth,
229    parslee_oauth_available: bool,
230    resolved: &std::collections::HashMap<String, bool>,
231) -> bool {
232    // Authentication is necessary but NOT sufficient for a PROXIED namespace.
233    // The OAuth2Pkce arm below resolves to exactly "is a Parslee session signed
234    // in", which is true regardless of what the gateway can actually serve —
235    // so ten `parslee/openrouter/*` aliases advertised `available` while every
236    // one of them 503'd with `openrouter_not_configured`, costing a benchmark
237    // sweep that picked one on the strength of that claim (Parslee-ai/car#786).
238    //
239    // There is no discovery endpoint to consult instead (`parslee.capabilities`
240    // enumerates product entitlements, not inference upstreams), so the gateway's
241    // own answer to a real request is the only truthful signal there is. Once it
242    // has told us, stop advertising — the catalog is then optimistic once and
243    // self-correcting, rather than permanently wrong.
244    //
245    // Checked here rather than at the call sites so `register` and
246    // `refresh_availability` cannot drift apart.
247    if crate::openrouter::is_curated_managed_gateway_alias(model_id)
248        && crate::openrouter::gateway_unconfigured()
249    {
250        return false;
251    }
252    // The neighbouring half of the same mistake, one layer earlier: a signed-in
253    // session is not a WORKING one. `access_token_is_available` is documented
254    // existence-only — it reports that a V2 record has an active slot, never
255    // that the token is still accepted — so a machine with a stale sign-in
256    // offered every managed alias as a top-quality candidate and 401'd on every
257    // one, per call, forever (Parslee-ai/car#887).
258    //
259    // Unlike the gateway check above this is NOT scoped to the curated
260    // OpenRouter aliases: a dead credential kills every `parslee/*` row, so the
261    // suppression belongs on the OAuth2Pkce arm as a whole.
262    //
263    // Checked here rather than at the call sites for the same reason as the
264    // gateway verdict — so `register` and `refresh_availability` cannot drift.
265    if crate::parslee_credential::credential_rejected()
266        && matches!(auth, ProprietaryAuth::OAuth2Pkce { .. })
267    {
268        return false;
269    }
270    match auth {
271        ProprietaryAuth::ApiKeyEnv { env_var } | ProprietaryAuth::BearerTokenEnv { env_var } => {
272            resolved.get(env_var).copied().unwrap_or(false)
273        }
274        ProprietaryAuth::OAuth2Pkce { .. } => {
275            schema_provider.eq_ignore_ascii_case("parslee")
276                && source_provider.eq_ignore_ascii_case("parslee")
277                && parslee_oauth_available
278        }
279    }
280}
281
282fn model_upgrade_rules() -> Vec<ModelUpgradeRule> {
283    serde_json::from_str(include_str!("../assets/model-upgrades.json"))
284        .expect("built-in model-upgrades.json should parse")
285}
286
287/// File name of the user-registered model config under the CAR state root.
288pub const USER_MODELS_FILE: &str = "models.json";
289
290/// The one resolver for `models.json`: [`USER_MODELS_FILE`] under the CAR state
291/// root (`~/.car/models.json` unless `CAR_HOME` moves the root).
292///
293/// Every reader and writer in the tree goes through this — the registry's own
294/// load/save, and the daemon's `models.register` / `models.unregister`
295/// handlers. They have to agree: when the write side moved to `CAR_HOME` and
296/// the read side stayed derived from the (deliberately unmoved) weights dir, a
297/// relocated daemon persisted a registration to one file and read a different
298/// one on the next boot, so the registration silently never took effect.
299///
300/// `None` only when there is no `CAR_HOME`, `HOME` or `USERPROFILE` — the same
301/// condition every other state path treats as "cannot resolve". The registry
302/// itself joins [`USER_MODELS_FILE`] onto the state root it was constructed
303/// with, which for [`UnifiedRegistry::new`] is the infallible
304/// `car_home::root_or_relative()`; same root, same file name, differing only in
305/// what they do when nothing resolves at all (error here, relative `.car`
306/// there — the pre-existing behaviour of each caller).
307pub fn user_config_path() -> Option<PathBuf> {
308    car_home::root().map(|root| root.join(USER_MODELS_FILE))
309}
310
311impl UnifiedRegistry {
312    /// Registry for the install that is actually running: state files resolve
313    /// under the CAR state root (`$CAR_HOME`, else `~/.car`), weights under
314    /// `models_dir`.
315    pub fn new(models_dir: PathBuf) -> Self {
316        Self::new_with_state_root(car_home::root_or_relative(), models_dir)
317    }
318
319    /// [`new`](Self::new) with the state root supplied rather than resolved
320    /// from the environment. The daemon passes its own resolved root; tests and
321    /// embedders that want every file inside one directory pass that directory.
322    pub fn new_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
323        let catalog_public_key = std::env::var("CAR_CATALOG_PUBKEY").ok();
324        Self::new_with_catalog_public_key(state_root, models_dir, catalog_public_key.as_deref())
325    }
326
327    fn new_with_catalog_public_key(
328        state_root: PathBuf,
329        models_dir: PathBuf,
330        catalog_public_key: Option<&str>,
331    ) -> Self {
332        // Production asks `car_auth` live and acts on the answer. Under
333        // `cfg(test)` the same question is asked the same way but held INERT, so
334        // building a registry — which every registry test does, and most of them
335        // without taking the crate's serial lock — reports identical
336        // availability while clearing nothing outside itself
337        // (Parslee-ai/car#986). A test that means to exercise the clear asks for
338        // it explicitly with `new_with_session(.., SessionProbe::Fixed(false))`.
339        #[cfg(not(test))]
340        let session = SessionProbe::Live;
341        #[cfg(test)]
342        let session = SessionProbe::Inert;
343        Self::new_with_session(state_root, models_dir, catalog_public_key, session)
344    }
345
346    /// [`new_with_catalog_public_key`](Self::new_with_catalog_public_key) with
347    /// the session answer supplied rather than defaulted. See [`SessionProbe`].
348    pub(crate) fn new_with_session(
349        state_root: PathBuf,
350        models_dir: PathBuf,
351        catalog_public_key: Option<&str>,
352        session: SessionProbe,
353    ) -> Self {
354        let user_config_path = state_root.join(USER_MODELS_FILE);
355
356        let mut registry = Self {
357            models_dir,
358            state_root,
359            models: HashMap::new(),
360            project_model_ids: HashSet::new(),
361            builtin_model_ids: HashSet::new(),
362            user_config_ids: HashSet::new(),
363            user_config_path,
364            ambient_progress: ProgressSink::none(),
365            session,
366        };
367        registry.load_builtin_catalog();
368        // Refreshable signed catalog (E1): a prior `refresh_catalog` stores the
369        // exact authenticated body + signature envelope. Startup re-verifies
370        // that envelope with the currently configured key before loading any
371        // model additively alongside the built-ins.
372        for schema in crate::catalog::load_cache(
373            &crate::catalog::cache_path(&registry.state_root),
374            catalog_public_key,
375        ) {
376            registry.register_signed_catalog_model(schema);
377        }
378        // Auto-discovered models (E2): Community-tier entries cached by a prior
379        // discovery pass (provider /v1/models). Loaded on top of built-ins +
380        // signed catalog, but never *overwriting* a curated/signed entry of the
381        // same id — discovery only ever ADDS models the catalog doesn't have.
382        for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
383            &registry.state_models_dir(),
384        )) {
385            if !registry.models.contains_key(&schema.id) {
386                registry.register(schema);
387            }
388        }
389        registry.refresh_availability();
390        // Load user config on top (silently ignore if missing)
391        let _ = registry.load_user_config();
392        // Surface models the user pulled/placed under ~/.car/models/ that no
393        // catalog or user-config entry covers, so a locally-present model is
394        // visible and routable instead of invisible (car-releases#62).
395        registry.discover_on_disk_models();
396        registry
397    }
398
399    fn empty_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
400        let user_config_path = state_root.join(USER_MODELS_FILE);
401        Self {
402            models_dir,
403            state_root,
404            models: HashMap::new(),
405            project_model_ids: HashSet::new(),
406            builtin_model_ids: HashSet::new(),
407            user_config_ids: HashSet::new(),
408            user_config_path,
409            ambient_progress: ProgressSink::none(),
410            // No refresh runs during construction, so this probe is never
411            // consulted. Keep it inert in case an internal caller refreshes.
412            session: SessionProbe::Inert,
413        }
414    }
415
416    /// Test-only: a registry with NO builtin catalog, signed cache, discovery
417    /// cache, or user config — only the models a test explicitly `register`s.
418    /// Routing unit tests that assert a synthetic model wins MUST use this:
419    /// `new()` loads the real builtin frontier models, which become routable
420    /// candidates the moment a test sets their `api_key_env` (e.g.
421    /// `OPENAI_API_KEY`) — and once those models carry real benchmark scores
422    /// (e.g. car-judged), they legitimately outrank the synthetic stand-ins and
423    /// silently break the test's assumption. An empty registry keeps the test
424    /// hermetic.
425    ///
426    /// The state root is taken to be `models_dir`'s parent, so a test that
427    /// passes `<tmp>/models` gets `<tmp>` and every state file stays inside the
428    /// temp dir. This constructor never reads or writes anything on its own, so
429    /// the derivation only matters if the test then calls `save_user_config`.
430    #[cfg(test)]
431    pub fn new_empty(models_dir: PathBuf) -> Self {
432        let state_root = models_dir.parent().unwrap_or(&models_dir).to_path_buf();
433        Self::empty_with_state_root(state_root, models_dir)
434    }
435
436    /// Registry for a deterministic offline diagnosis fixture.
437    ///
438    /// It reads only the explicitly named models directory: no builtin,
439    /// signed, discovered, or user catalog is loaded, and no credential or
440    /// session availability probe runs. Local model directories are inferred
441    /// with the same conservative on-disk discovery used by production.
442    pub(crate) fn new_isolated_for_diagnosis(state_root: PathBuf, models_dir: PathBuf) -> Self {
443        let mut registry = Self::empty_with_state_root(state_root, models_dir);
444        registry.discover_on_disk_models();
445        registry
446    }
447
448    /// Where the registry's own state that has always sat beside the weights
449    /// lives: `models/` under the state root. Identical to `models_dir`
450    /// whenever `CAR_HOME` is unset, which is why no existing install's files
451    /// move; different — and per-daemon — once it is set.
452    fn state_models_dir(&self) -> PathBuf {
453        self.state_root.join("models")
454    }
455
456    /// Register on-disk models under `models_dir` that nothing else already
457    /// covers. Curated catalog entries already light up via the
458    /// availability check (their `name` maps to `<models_dir>/<name>`), so
459    /// this only fills the gap for *uncatalogued* local models — e.g. a
460    /// model a user fetched by hand.
461    ///
462    /// Capability guessing from a bare directory is unreliable, and
463    /// mis-tagging a speech/vision/video checkpoint as `Generate` would
464    /// poison routing. So this is deliberately conservative: it only
465    /// registers text LLMs it can positively identify by a recognized
466    /// `model_type` in `config.json` (MLX) or by a GGUF weight file, infers
467    /// embed/rerank from the directory name, and skips everything else.
468    /// car-releases#62.
469    fn discover_on_disk_models(&mut self) {
470        let entries = match std::fs::read_dir(&self.models_dir) {
471            Ok(e) => e,
472            Err(_) => return,
473        };
474        // Names already registered (case-insensitive) — never shadow a
475        // curated/user/discovered entry with an inferred one.
476        let known: std::collections::HashSet<String> = self
477            .models
478            .values()
479            .map(|m| m.name.to_ascii_lowercase())
480            .collect();
481
482        for entry in entries.flatten() {
483            let path = entry.path();
484            if !path.is_dir() {
485                continue;
486            }
487            let Some(name) = path
488                .file_name()
489                .and_then(|n| n.to_str())
490                .map(str::to_string)
491            else {
492                continue;
493            };
494            if known.contains(&name.to_ascii_lowercase()) {
495                continue;
496            }
497
498            let Some(schema) = synthesize_local_schema(&name, &path) else {
499                continue;
500            };
501            tracing::info!(
502                id = %schema.id,
503                name = %name,
504                "auto-discovered uncatalogued local model under models_dir (car-releases#62)"
505            );
506            self.register(schema);
507        }
508    }
509
510    /// Register a model at a public runtime boundary.
511    ///
512    /// Callers cannot confer project curation: even a legacy schema whose
513    /// omitted `trust_tier` deserializes as `Curated` is normalized to
514    /// `Community` here. Project-owned builtins and signature-verified catalogs
515    /// use the crate-private [`register_project_model`](Self::register_project_model)
516    /// boundary instead.
517    pub fn register(&mut self, mut schema: ModelSchema) {
518        if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
519            warn!(id = %schema.id, "ignoring user registration for reserved Parslee-managed alias");
520            return;
521        }
522        if self.project_model_ids.contains(&schema.id) {
523            warn!(id = %schema.id, "ignoring public registration for project-owned exact id");
524            return;
525        }
526        schema.mark_user_registered();
527        self.register_preserving_trust(schema);
528    }
529
530    /// Register a model whose provenance was established by CAR itself.
531    ///
532    /// This trust-preserving path is intentionally crate-private. Production
533    /// callers are limited to compiled builtins and signature-verified catalog
534    /// rows; tests may use it to construct project-curated fixtures.
535    pub(crate) fn register_project_model(&mut self, schema: ModelSchema) -> bool {
536        let id = schema.id.clone();
537        if !self.register_preserving_trust(schema) {
538            return false;
539        }
540        self.project_model_ids.insert(id);
541        true
542    }
543
544    /// Add one signature-verified catalog row without allowing the remote
545    /// publisher to redefine an exact id already owned by this build or an
546    /// earlier authenticated row in the same document.
547    fn register_signed_catalog_model(&mut self, schema: ModelSchema) {
548        if self.builtin_model_ids.contains(&schema.id) {
549            warn!(id = %schema.id, "ignoring signed catalog row for compiled builtin exact id");
550            return;
551        }
552        if self.project_model_ids.contains(&schema.id) {
553            warn!(id = %schema.id, "ignoring duplicate signed catalog row for project-owned exact id");
554            return;
555        }
556        self.register_project_model(schema);
557    }
558
559    fn register_preserving_trust(&mut self, mut schema: ModelSchema) -> bool {
560        if let Err(error) = crate::catalog_identity::row_digest(&schema) {
561            warn!(id = %schema.id, %error, "rejecting model without canonical catalog identity");
562            return false;
563        }
564        // Check availability for local models
565        if schema.is_mlx() || schema.is_car_managed_vllm_mlx() {
566            // MLX requires Apple Silicon Metal. On any other build target —
567            // Intel Mac, Linux, Windows, or `car_skip_mlx` — the backend
568            // is not compiled in and any execution attempt would fail at
569            // dispatch with a "model not found" or backend-missing error.
570            // Mirror the AppleFoundationModels cfg-gating below: the
571            // registry must reflect that the model is *not* runnable here
572            // so the adaptive router doesn't add it to fallback chains
573            // (Parslee-ai/car#231 — §7.1, the "fresh Windows install has
574            // no usable inference path" finding). Same shape as the
575            // `refresh_availability` MLX branch.
576            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
577            {
578                schema.available = if schema.tags.contains(&"speech".to_string()) {
579                    speech_mlx_available()
580                } else if let ModelSource::Mlx { ref hf_repo, .. }
581                | ModelSource::ManagedVllmMlx { ref hf_repo, .. } = schema.source
582                {
583                    // Available if cached locally OR has an hf_repo —
584                    // ensure_local() lazy-downloads on first use, so a
585                    // declared hf_repo is "functionally available" the
586                    // same way Ollama/RemoteApi entries are. Mirrors the
587                    // refresh_availability() check below; see #164.
588                    let mlx_dir = self.models_dir.join(&schema.name);
589                    mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty()
590                } else {
591                    let mlx_dir = self.models_dir.join(&schema.name);
592                    mlx_dir_has_weights(&mlx_dir)
593                };
594            }
595            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
596            {
597                schema.available = false;
598            }
599        } else if schema.is_vllm_mlx() {
600            // vLLM-MLX: available if endpoint env var set or was manually marked available
601            schema.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available;
602        } else if matches!(schema.source, ModelSource::WhisperCpp { .. }) {
603            // whisper.cpp compiles on every platform and lazy-downloads its ggml
604            // model on first use, so it's functionally available everywhere
605            // (mirrors refresh_availability). Checked before the is_local() GGUF
606            // branch below — whisper is_local() too but has no model.gguf.
607            schema.available = true;
608        } else if matches!(schema.source, ModelSource::WindowsSpeech {}) {
609            // OS-provided WinRT synthesizer — available on Windows only.
610            schema.available = cfg!(target_os = "windows");
611        } else if schema.is_codex_cli() {
612            // The CLI owns auth; availability only answers whether there is a
613            // binary to attempt. Login failures surface from the child without
614            // CAR reading Codex's credential store.
615            schema.available = crate::backend::codex_cli::is_available();
616        } else if schema.is_local() {
617            let local_path = self.models_dir.join(&schema.name).join("model.gguf");
618            // Mirrors the `ModelSource::Local` arm in `refresh_availability`:
619            // where the Candle GGUF backend exists, a declared `hf_repo` is
620            // functionally available because `ensure_local` fetches from it.
621            // Registration and refresh must agree, or a model's availability
622            // flips depending on which ran last.
623            let lazily_fetchable = matches!(
624                schema.source,
625                ModelSource::Local { ref hf_repo, .. } if !hf_repo.is_empty()
626            ) && !cfg!(all(
627                target_os = "macos",
628                target_arch = "aarch64",
629                not(car_skip_mlx)
630            ));
631            schema.available = local_path.exists() || lazily_fetchable;
632        } else if schema.is_remote() {
633            // Registration is catalog work, so it may use environment presence
634            // and non-secret authority hints but must never query a secret
635            // backend. Request-time snapshots refresh this authoritatively.
636            schema.available = match schema.source {
637                ModelSource::RemoteApi {
638                    protocol: crate::schema::ApiProtocol::OpenRouter,
639                    ..
640                } => crate::openrouter::credential_source().is_some(),
641                ModelSource::RemoteApi {
642                    ref api_key_env, ..
643                } => environment_credential_available(api_key_env),
644                ModelSource::Proprietary {
645                    ref provider,
646                    ref auth,
647                    ..
648                } => {
649                    let resolved = match auth {
650                        ProprietaryAuth::ApiKeyEnv { env_var }
651                        | ProprietaryAuth::BearerTokenEnv { env_var } => {
652                            std::collections::HashMap::from([(
653                                env_var.clone(),
654                                environment_credential_available(env_var),
655                            )])
656                        }
657                        ProprietaryAuth::OAuth2Pkce { .. } => Default::default(),
658                    };
659                    proprietary_auth_available(
660                        &schema.id,
661                        &schema.provider,
662                        provider,
663                        auth,
664                        self.session.available(),
665                        &resolved,
666                    )
667                }
668                _ => schema.available,
669            };
670        }
671        // Ready = usable without a download. For anything local that means
672        // weights resolvable on disk *now*; `available` alone can't answer this,
673        // since a declared `hf_repo` makes an MLX model "available" before a
674        // byte is fetched (#164). Remote models never need a weights download.
675        // Their live credential state belongs in `available`; coupling it to
676        // `weights_ready` permanently excludes models registered before a key
677        // is connected from `require_ready` routes.
678        // See `ModelSchema::weights_ready` (Parslee-ai/car#638).
679        // NOTE: `is_mlx()` must be tested before `is_local()` — `is_local()`
680        // returns true for `ModelSource::Mlx` too, so the GGUF branch would
681        // otherwise swallow every MLX model and look for a `model.gguf` that
682        // never exists.
683        schema.weights_ready = physical_weights_ready(&schema, &self.models_dir);
684        info!(
685            id = %schema.id,
686            name = %schema.name,
687            available = schema.available,
688            weights_ready = schema.weights_ready,
689            "registered model"
690        );
691        self.models.insert(schema.id.clone(), schema);
692        true
693    }
694
695    /// Register a schema that crossed the user-controlled configuration
696    /// boundary.
697    ///
698    /// This is deliberately separate from [`register`](Self::register):
699    /// builtin, signed-catalog, and discovery rows must never become
700    /// `models.json` rows merely because they share the same in-memory map.
701    pub fn register_user_model(&mut self, mut schema: ModelSchema) {
702        if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
703            warn!(id = %schema.id, "ignoring persisted user model for reserved Parslee-managed alias");
704            return;
705        }
706        if self.project_model_ids.contains(&schema.id) {
707            warn!(id = %schema.id, "ignoring persisted user model for project-owned exact id");
708            return;
709        }
710        schema.mark_user_registered();
711        let id = schema.id.clone();
712        if self.register_preserving_trust(schema) {
713            self.user_config_ids.insert(id);
714        }
715    }
716
717    /// Unregister a model by id. Returns the removed schema if found.
718    pub fn unregister(&mut self, id: &str) -> Option<ModelSchema> {
719        let removed = self.models.remove(id);
720        if let Some(ref m) = removed {
721            info!(id = %m.id, "unregistered model");
722        }
723        removed
724    }
725
726    /// Unregister an explicitly user-configured model.
727    ///
728    /// An untracked builtin, signed-catalog, or discovery row is not removable
729    /// through this persistence boundary.
730    pub fn unregister_user_model(&mut self, id: &str) -> Option<ModelSchema> {
731        if !self.user_config_ids.remove(id) {
732            return None;
733        }
734        self.unregister(id)
735    }
736
737    /// List all models.
738    pub fn list(&self) -> Vec<&ModelSchema> {
739        let mut models: Vec<&ModelSchema> = self.models.values().collect();
740        models.sort_by(|a, b| a.id.cmp(&b.id));
741        models
742    }
743
744    /// Query models matching a filter.
745    pub fn query(&self, filter: &ModelFilter) -> Vec<&ModelSchema> {
746        self.models
747            .values()
748            .filter(|m| {
749                // Capability check: model must have ALL required capabilities
750                if !filter.capabilities.iter().all(|c| m.has_capability(*c)) {
751                    return false;
752                }
753                // Size check
754                if let Some(max) = filter.max_size_mb {
755                    if m.size_mb() > max && m.is_local() {
756                        return false;
757                    }
758                }
759                // Latency check (declared envelope)
760                if let Some(max) = filter.max_latency_ms {
761                    if let Some(p50) = m.performance.latency_p50_ms {
762                        if p50 > max {
763                            return false;
764                        }
765                    }
766                }
767                // Cost check
768                if let Some(max) = filter.max_cost_per_mtok {
769                    if let Some(cost) = m.cost.output_per_mtok {
770                        if cost > max {
771                            return false;
772                        }
773                    }
774                }
775                // Tag check
776                if !filter.tags.iter().all(|t| m.tags.contains(t)) {
777                    return false;
778                }
779                // Provider check
780                if let Some(ref p) = filter.provider {
781                    if &m.provider != p {
782                        return false;
783                    }
784                }
785                // Local only
786                if filter.local_only && !m.is_local() {
787                    return false;
788                }
789                // Available only
790                if filter.available_only && !m.available_now() {
791                    return false;
792                }
793                true
794            })
795            .collect()
796    }
797
798    /// Query models by a single capability.
799    pub fn query_by_capability(&self, cap: ModelCapability) -> Vec<&ModelSchema> {
800        self.query(&ModelFilter {
801            capabilities: vec![cap],
802            ..Default::default()
803        })
804    }
805
806    /// Report installed local models with curated newer replacements.
807    pub fn available_upgrades(&self) -> Vec<ModelUpgrade> {
808        let mut upgrades = Vec::new();
809        for rule in model_upgrade_rules() {
810            let Some(from) = rule
811                .from_ids
812                .iter()
813                .find_map(|id| self.models.get(id.as_str()))
814                .filter(|schema| schema.available)
815            else {
816                continue;
817            };
818            let Some(to) = self.models.get(rule.to_id.as_str()) else {
819                continue;
820            };
821            upgrades.push(ModelUpgrade {
822                from_id: from.id.clone(),
823                from_name: from.name.clone(),
824                to_id: to.id.clone(),
825                to_name: to.name.clone(),
826                reason: rule.reason.clone(),
827                target_runtime: rule.target_runtime.clone(),
828                target_runtime_requirement: rule.target_runtime_requirement.clone(),
829                minimum_runtimes: rule.minimum_runtimes.clone(),
830                target_available: to.available,
831                target_pullable: matches!(
832                    to.source,
833                    ModelSource::Local { .. } | ModelSource::Mlx { .. }
834                ),
835                remove_old_supported: matches!(
836                    from.source,
837                    ModelSource::Local { .. } | ModelSource::Mlx { .. }
838                ) && rule.remove_old_after_available,
839            });
840        }
841        upgrades.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
842        upgrades.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
843        upgrades
844    }
845
846    /// Get a specific model by id.
847    pub fn get(&self, id: &str) -> Option<&ModelSchema> {
848        self.models.get(id)
849    }
850
851    /// Return the currently registered schema without refreshing availability.
852    ///
853    /// Callers that need runtime credential/file state must explicitly use a
854    /// refreshing snapshot; identity/provenance checks should use this exact
855    /// stored row instead.
856    pub fn registered_schema(&self, id: &str) -> Option<&ModelSchema> {
857        self.get(id)
858    }
859
860    /// Iterate all registered model schemas (built-in + signed + discovered).
861    pub fn all(&self) -> impl Iterator<Item = &ModelSchema> {
862        self.models.values()
863    }
864
865    /// Find a model by name (case-insensitive). For backward compatibility
866    /// with the old registry that used short names like "Qwen3-4B".
867    pub fn find_by_name(&self, name: &str) -> Option<&ModelSchema> {
868        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
869        if !name.to_ascii_lowercase().ends_with("-mlx") {
870            if let Some(mlx_variant) = self
871                .models
872                .values()
873                .find(|m| m.name.eq_ignore_ascii_case(&format!("{name}-MLX")))
874            {
875                return Some(mlx_variant);
876            }
877        }
878
879        self.models
880            .values()
881            .find(|m| m.name.eq_ignore_ascii_case(name))
882    }
883
884    /// On Apple Silicon, resolve a GGUF/Candle model to its MLX equivalent.
885    /// Returns the MLX model schema if one exists with the same family and
886    /// matching capabilities; otherwise returns None.
887    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
888    pub fn resolve_mlx_equivalent(&self, schema: &ModelSchema) -> Option<&ModelSchema> {
889        // Already MLX — no redirect needed.
890        if schema.is_mlx() || schema.is_vllm_mlx() {
891            return None;
892        }
893        // Only redirect local GGUF models.
894        if !matches!(schema.source, ModelSource::Local { .. }) {
895            return None;
896        }
897        // Find the MLX twin: same family, SAME PARAMETER COUNT, and at least
898        // the same primary capability. Family alone is too coarse — every
899        // Qwen3 size shares family "qwen3", so a family-only `.find()` could
900        // map an 8B GGUF to a 4B MLX model (whichever the HashMap yielded
901        // first), silently swapping model size at execution and routing.
902        // Keying on param_count makes the resolution deterministic and 1:1.
903        let primary_cap = schema.capabilities.first()?;
904        self.models.values().find(|m| {
905            m.is_mlx()
906                && m.family == schema.family
907                && m.param_count == schema.param_count
908                && m.capabilities.contains(primary_cap)
909        })
910    }
911
912    /// Ensure a local model is downloaded, returning its local directory path.
913    pub async fn ensure_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
914        // Report to the ambient sink rather than dropping the events on the
915        // floor — see [`Self::ambient_progress`] (Parslee-ai/car#620). Still a
916        // no-op sink unless an embedder opted in, so this is not a behaviour
917        // change for anyone who hasn't.
918        let sink = self.ambient_progress.clone();
919        self.ensure_local_with_progress(id, &sink).await
920    }
921
922    /// Route implicit-download progress to `sink`.
923    ///
924    /// Applies to acquisitions nobody explicitly requested — the ones
925    /// [`ensure_local`](Self::ensure_local) performs mid-`generate` when the
926    /// router lands on a model that isn't on disk. Explicit pulls take their
927    /// sink as an argument and ignore this.
928    pub fn set_ambient_progress(&mut self, sink: ProgressSink) {
929        self.ambient_progress = sink;
930    }
931
932    /// Like [`ensure_local`](Self::ensure_local) but drives a progress sink and
933    /// enforces the acquisition lifecycle: a per-model lock (so this can't race
934    /// a concurrent pull / remove / upgrade of the same model), a disk-space
935    /// preflight, and `Started`/`Completed`/`Failed` events around the work.
936    pub async fn ensure_local_with_progress(
937        &self,
938        id: &str,
939        sink: &ProgressSink,
940    ) -> Result<PathBuf, InferenceError> {
941        self.acquire_and_ensure(id, sink, false, None).await
942    }
943
944    /// Download into a caller-created private staging directory. The caller
945    /// atomically publishes that directory only after the complete acquisition
946    /// succeeds, so a pre-existing same-name directory can never be promoted
947    /// into CAR ownership by a pull.
948    pub(crate) async fn ensure_local_with_progress_staged(
949        &self,
950        id: &str,
951        sink: &ProgressSink,
952        staging_dir: &Path,
953    ) -> Result<PathBuf, InferenceError> {
954        self.acquire_and_ensure(id, sink, false, Some(staging_dir))
955            .await
956    }
957
958    /// Force a complete re-acquisition: skip the "reuse what's already on disk"
959    /// short-circuits so every expected file is re-checked and any missing one
960    /// (e.g. a blob the self-heal path purged as provably corrupt) is
961    /// re-downloaded. The per-file checks still skip intact files, so this
962    /// re-fetches only what's actually gone — not the whole model.
963    pub async fn redownload_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
964        self.acquire_and_ensure(id, &ProgressSink::none(), true, None)
965            .await
966    }
967
968    async fn acquire_and_ensure(
969        &self,
970        id: &str,
971        sink: &ProgressSink,
972        force: bool,
973        managed_dir_override: Option<&Path>,
974    ) -> Result<PathBuf, InferenceError> {
975        let schema = self
976            .get(id)
977            .or_else(|| self.find_by_name(id))
978            .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
979        let model_name = schema.name.clone();
980        let model_id = schema.id.clone();
981        let needed_mb = schema.size_mb();
982        let model_dir = self.models_dir.join(&schema.name);
983
984        // Serialize acquisition of this model id against other pull/remove tasks.
985        let _guard = crate::download::acquire_model_lock(&model_id).await;
986
987        // Preflight: fail fast if there isn't room, before touching disk.
988        if let Err(e) = crate::download::check_disk_space(&model_dir, needed_mb) {
989            sink.emit(DownloadEvent::Failed { error: e.clone() });
990            return Err(InferenceError::DownloadFailed(e));
991        }
992
993        sink.emit(DownloadEvent::Started {
994            model: model_name.clone(),
995            total_files: 0,
996            total_mb: needed_mb,
997        });
998        let result = self
999            .ensure_local_inner(id, sink, force, managed_dir_override)
1000            .await;
1001        match &result {
1002            Ok(_) => sink.emit(DownloadEvent::Completed { model: model_name }),
1003            Err(e) => sink.emit(DownloadEvent::Failed {
1004                error: e.to_string(),
1005            }),
1006        }
1007        result
1008    }
1009
1010    async fn ensure_local_inner(
1011        &self,
1012        id: &str,
1013        sink: &ProgressSink,
1014        force: bool,
1015        managed_dir_override: Option<&Path>,
1016    ) -> Result<PathBuf, InferenceError> {
1017        let schema = self
1018            .get(id)
1019            .or_else(|| self.find_by_name(id))
1020            .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1021
1022        match &schema.source {
1023            ModelSource::Local {
1024                hf_repo,
1025                hf_filename,
1026                tokenizer_repo,
1027            } => {
1028                let model_dir = managed_dir_override
1029                    .map(Path::to_path_buf)
1030                    .unwrap_or_else(|| self.models_dir.join(&schema.name));
1031                let model_path = model_dir.join("model.gguf");
1032                let tokenizer_path = model_dir.join("tokenizer.json");
1033
1034                if !force
1035                    && crate::download::cache_file_usable(&model_path)
1036                    && crate::download::cache_file_usable(&tokenizer_path)
1037                {
1038                    return Ok(model_dir);
1039                }
1040
1041                // A row with no `hf_repo` came from the local-directory scan:
1042                // its weights are whatever the user put on disk, and there is
1043                // nowhere to fetch the missing pieces from. Downloading with an
1044                // empty repo asks HuggingFace for `https://huggingface.co//…`
1045                // and reports whatever that returns, which explains nothing.
1046                // Say what CAR actually needs instead.
1047                if hf_repo.is_empty() {
1048                    let missing = [
1049                        ("model.gguf", &model_path),
1050                        ("tokenizer.json", &tokenizer_path),
1051                    ]
1052                    .into_iter()
1053                    .filter(|(_, path)| !crate::download::cache_file_usable(path))
1054                    .map(|(name, _)| name)
1055                    .collect::<Vec<_>>()
1056                    .join(" and ");
1057                    return Err(InferenceError::InferenceFailed(format!(
1058                        "{}: discovered on disk at {} but not loadable — the GGUF \
1059                         backend reads `model.gguf` and `tokenizer.json` from the \
1060                         model directory, and this one is missing {missing}. Rename \
1061                         the weight file to `model.gguf` and add the tokenizer, or \
1062                         register the model against its HuggingFace repo so CAR can \
1063                         fetch both.",
1064                        schema.name,
1065                        model_dir.display()
1066                    )));
1067                }
1068
1069                std::fs::create_dir_all(&model_dir)?;
1070
1071                if !crate::download::cache_file_usable(&model_path) {
1072                    info!(model = %schema.name, repo = %hf_repo, "downloading model weights");
1073                    sink.emit(DownloadEvent::FileStarted {
1074                        filename: "model weights".into(),
1075                        index: 1,
1076                        total_files: 2,
1077                        size_mb: schema.size_mb(),
1078                    });
1079                    download_file(hf_repo, hf_filename, &model_path).await?;
1080                    sink.emit(DownloadEvent::FileCompleted {
1081                        filename: "model weights".into(),
1082                    });
1083                }
1084                if !crate::download::cache_file_usable(&tokenizer_path) {
1085                    info!(model = %schema.name, repo = %tokenizer_repo, "downloading tokenizer");
1086                    sink.emit(DownloadEvent::FileStarted {
1087                        filename: "tokenizer".into(),
1088                        index: 2,
1089                        total_files: 2,
1090                        size_mb: 0,
1091                    });
1092                    download_file(tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
1093                    sink.emit(DownloadEvent::FileCompleted {
1094                        filename: "tokenizer".into(),
1095                    });
1096                }
1097
1098                Ok(model_dir)
1099            }
1100            ModelSource::Mlx {
1101                hf_repo,
1102                hf_weight_file,
1103            }
1104            | ModelSource::ManagedVllmMlx {
1105                hf_repo,
1106                hf_weight_file,
1107            } => {
1108                let model_dir = managed_dir_override
1109                    .map(Path::to_path_buf)
1110                    .unwrap_or_else(|| self.models_dir.join(&schema.name));
1111                let config_path = model_dir.join("config.json");
1112
1113                // Diffusers-layout models (Flux image-gen, LTX video) keep their
1114                // weights in component subdirs (transformer/, vae/, text_encoder/,
1115                // tokenizer/) and ship NO root config.json / top-level weight
1116                // index. The standard MLX flow below hard-fetches config.json
1117                // (404 for these repos) and a weight index (absent), so they need
1118                // the whole-repo snapshot path. Detect by capability so DOWNLOAD,
1119                // REUSE, and (via mlx_dir_has_weights) LOAD all agree on the
1120                // layout — the recurring image-gen 404 was fixing only the load
1121                // path (recurse subdirs) while download + reuse still demanded a
1122                // config.json these repos don't have.
1123                let is_diffusers = schema.capabilities.iter().any(|c| {
1124                    matches!(
1125                        c,
1126                        ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
1127                    )
1128                });
1129
1130                // Reuse the managed dir ONLY if it actually holds weights — a
1131                // config-only stub (or dangling-symlink install) must fall
1132                // through to the download below, not no-op (car-releases#391).
1133                // `force` (self-heal re-pull) skips reuse so a purged shard is
1134                // actually re-fetched rather than masked by the surviving shards.
1135                // Standard MLX also gates on the root config.json; diffusers has
1136                // none, so recursive weights-present is its completeness signal.
1137                if !force
1138                    && mlx_dir_has_weights(&model_dir)
1139                    && (is_diffusers || config_path.exists())
1140                {
1141                    ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1142                    info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1143                    return Ok(model_dir);
1144                }
1145
1146                // Same guard for a cached HF snapshot: a partial/pruned snapshot
1147                // whose weights don't resolve isn't usable — re-download instead.
1148                if !force {
1149                    if let Some(snapshot_dir) =
1150                        latest_huggingface_repo_snapshot(hf_repo).filter(|d| mlx_dir_has_weights(d))
1151                    {
1152                        ensure_auxiliary_mlx_files(&schema.name, hf_repo, &snapshot_dir).await?;
1153                        info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1154                        return Ok(snapshot_dir);
1155                    }
1156                }
1157
1158                std::fs::create_dir_all(&model_dir)?;
1159
1160                info!(model = %schema.name, repo = %hf_repo, "downloading MLX model");
1161
1162                // Diffusers-layout: mirror the whole repo (component subdirs
1163                // preserved). There is no root config.json or weight index to
1164                // drive the file-by-file flow below, and demanding one is the
1165                // 404 that broke image/video generation. Verify component weights
1166                // actually landed so a half-finished pull can't masquerade as
1167                // installed (the false-availability the caller relies on).
1168                if is_diffusers {
1169                    download_repo_snapshot(hf_repo, &model_dir, sink).await?;
1170                    ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1171                    if !mlx_dir_has_weights(&model_dir) {
1172                        return Err(InferenceError::DownloadFailed(format!(
1173                            "{hf_repo}: snapshot fetched but no component weights found"
1174                        )));
1175                    }
1176                    info!(model = %schema.name, path = %model_dir.display(), "downloaded diffusers model");
1177                    return Ok(model_dir);
1178                }
1179
1180                // Download config, tokenizer, and weight files. total_files is
1181                // not known up front for MLX (single vs sharded), so events
1182                // carry total_files = 0 ("unknown") and the UI shows names.
1183                emit_file(sink, "config", 0, schema.size_mb());
1184                download_file(hf_repo, "config.json", &config_path).await?;
1185                download_tokenizer_assets(hf_repo, &model_dir, sink).await;
1186                let tok_config_path = model_dir.join("tokenizer_config.json");
1187                if !crate::download::cache_file_usable(&tok_config_path) {
1188                    let _ = download_file(hf_repo, "tokenizer_config.json", &tok_config_path).await;
1189                }
1190
1191                // Download weight files
1192                if let Some(ref wf) = hf_weight_file {
1193                    let wf_path = model_dir.join(wf);
1194                    if !crate::download::cache_file_usable(&wf_path) {
1195                        emit_file(sink, "model weights", 0, schema.size_mb());
1196                        download_file(hf_repo, wf, &wf_path).await?;
1197                    }
1198                } else {
1199                    // Try single file first, then sharded
1200                    let single = model_dir.join("model.safetensors");
1201                    if !crate::download::cache_file_usable(&single) {
1202                        emit_file(sink, "model weights", 0, schema.size_mb());
1203                        match download_file(hf_repo, "model.safetensors", &single).await {
1204                            Ok(()) => {}
1205                            Err(_) => {
1206                                // Sharded: download index and then each shard
1207                                let index_path = model_dir.join("model.safetensors.index.json");
1208                                download_file(hf_repo, "model.safetensors.index.json", &index_path)
1209                                    .await?;
1210
1211                                let index_json: serde_json::Value =
1212                                    serde_json::from_str(&std::fs::read_to_string(&index_path)?)
1213                                        .map_err(|e| {
1214                                            InferenceError::InferenceFailed(format!(
1215                                                "parse index: {e}"
1216                                            ))
1217                                        })?;
1218
1219                                if let Some(weight_map) =
1220                                    index_json.get("weight_map").and_then(|m| m.as_object())
1221                                {
1222                                    let mut files: std::collections::HashSet<String> =
1223                                        std::collections::HashSet::new();
1224                                    for filename in weight_map.values() {
1225                                        if let Some(f) = filename.as_str() {
1226                                            files.insert(f.to_string());
1227                                        }
1228                                    }
1229                                    let shard_total = files.len() as u32;
1230                                    for (i, file) in files.iter().enumerate() {
1231                                        let dest = model_dir.join(file);
1232                                        if !crate::download::cache_file_usable(&dest) {
1233                                            info!(file = %file, "downloading weight shard");
1234                                            sink.emit(DownloadEvent::FileStarted {
1235                                                filename: format!("weights part {}", i + 1),
1236                                                index: (i + 1) as u32,
1237                                                total_files: shard_total,
1238                                                size_mb: 0,
1239                                            });
1240                                            download_file(hf_repo, file, &dest).await?;
1241                                            sink.emit(DownloadEvent::FileCompleted {
1242                                                filename: format!("weights part {}", i + 1),
1243                                            });
1244                                        }
1245                                    }
1246                                }
1247                            }
1248                        }
1249                    }
1250                }
1251
1252                ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1253
1254                // Verify what we actually landed, exactly as the diffusers branch
1255                // above does (#808). Without this, a pull whose shard fetch was
1256                // interrupted returned `Ok(model_dir)` — indistinguishable from
1257                // success — and the model stayed broken until inference failed
1258                // with `load model-00001-of-00002.safetensors: Path must point to
1259                // a local file`, inside whatever job was running.
1260                //
1261                // A caller reaching for the two obvious mechanisms — check
1262                // availability, then pull — got "yes" and "ok" from both while
1263                // the model was unusable. Report the missing shards by name so
1264                // the failure is actionable at pull time.
1265                let missing = missing_weight_shards(&model_dir);
1266                if !missing.is_empty() {
1267                    return Err(InferenceError::DownloadFailed(format!(
1268                        "{}: pull finished but {} weight shard(s) are still missing: {}. \
1269                         The download was interrupted; re-run the pull to resume it.",
1270                        schema.name,
1271                        missing.len(),
1272                        missing.join(", ")
1273                    )));
1274                }
1275                if !mlx_dir_has_weights(&model_dir) {
1276                    return Err(InferenceError::DownloadFailed(format!(
1277                        "{}: pull finished but no usable weights are present under {}",
1278                        schema.name,
1279                        model_dir.display()
1280                    )));
1281                }
1282                Ok(model_dir)
1283            }
1284            _ => Err(InferenceError::InferenceFailed(format!(
1285                "model {} is not local",
1286                id
1287            ))),
1288        }
1289    }
1290
1291    /// Legacy registry-local deletion is intentionally disabled because it
1292    /// recursively removed shared Hugging Face caches without receipts or
1293    /// runtime/lease coordination. Use `InferenceEngine::remove_model_from_car`.
1294    #[deprecated(note = "use InferenceEngine::remove_model_from_car")]
1295    pub fn remove_local(&mut self, id: &str) -> Result<(), InferenceError> {
1296        Err(InferenceError::InferenceFailed(format!(
1297            "legacy registry removal for {id} is disabled; use receipt-backed model management"
1298        )))
1299    }
1300
1301    /// Refresh passive catalog availability flags for all models.
1302    ///
1303    /// Physical local/runtime probes remain live, but credential-backed rows
1304    /// use only environment presence and the non-secret Parslee authority hint.
1305    /// This method is safe for startup, catalog, setup, and health surfaces.
1306    pub fn refresh_availability(&mut self) {
1307        let parslee_oauth_available = self.session.available();
1308        self.refresh_availability_with(
1309            parslee_oauth_available,
1310            self.session.signed_out() && self.session.may_forget_session_evidence(),
1311            false,
1312        );
1313    }
1314
1315    /// Refresh availability for an explicit request-time routing/use path.
1316    /// `parslee_api_base` comes from Task 2's coordinator-owned credential
1317    /// snapshot; `parslee_signed_out` is true only for an authoritative absence,
1318    /// never for denial/cooldown/unreadable failures.
1319    pub(crate) fn refresh_routing_availability(
1320        &mut self,
1321        parslee_api_base: Option<&str>,
1322        parslee_signed_out: bool,
1323    ) {
1324        if let Some(api_base) = parslee_api_base {
1325            let api_base = api_base.trim_end_matches('/');
1326            for schema in self.models.values_mut() {
1327                if schema.provider.eq_ignore_ascii_case("parslee") {
1328                    if let ModelSource::Proprietary {
1329                        provider, endpoint, ..
1330                    } = &mut schema.source
1331                    {
1332                        if provider.eq_ignore_ascii_case("parslee") {
1333                            *endpoint = api_base.to_string();
1334                        }
1335                    }
1336                }
1337            }
1338        }
1339        self.refresh_availability_with(parslee_api_base.is_some(), parslee_signed_out, true);
1340    }
1341
1342    fn refresh_availability_with(
1343        &mut self,
1344        parslee_oauth_available: bool,
1345        clear_parslee_observations: bool,
1346        authoritative_credentials: bool,
1347    ) {
1348        // `models_dir` is consumed only inside the MLX and Local arms.
1349        // On non-MLX targets the MLX arm is a cfg-gated `available = false`
1350        // and never touches `models_dir`; the Local arm still needs it.
1351        let models_dir = self.models_dir.clone();
1352        // mlx-vlm CLI is the same probe call no matter which model
1353        // requires it; do it once per refresh, not per-model. On
1354        // non-MLX targets the variable is unused (the consuming arm
1355        // is cfg-gated out), so suppress the unused-variable warning.
1356        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1357        let mlx_vlm_cli_present = crate::backend::mlx_vlm_cli::is_available();
1358        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1359        #[allow(unused_variables)]
1360        let mlx_vlm_cli_present = false;
1361        // A learned "gateway has no OpenRouter upstream" is about the
1362        // environment behind THIS session. When the session is gone, so is the
1363        // evidence — otherwise a sign-in to a properly-configured org would
1364        // inherit the previous one's suppression (Parslee-ai/car#786).
1365        if clear_parslee_observations {
1366            crate::openrouter::clear_gateway_unconfigured();
1367            // Same reasoning for the credential verdict: it was learned about
1368            // the credential that is now gone, and inheriting it would suppress
1369            // the managed lanes through the next sign-in (Parslee-ai/car#887).
1370            crate::parslee_credential::clear_credential_rejected();
1371        }
1372
1373        // Resolve each DISTINCT credential once, not once per model
1374        // (car-releases#75).
1375        //
1376        // Every `RemoteApi` and `Proprietary` row used to call
1377        // `resolve_env_or_keychain` inside the loop below, and on macOS that is
1378        // a keychain query — ~14 ms each. A stock catalog is ~72 models over
1379        // roughly a dozen providers, so a refresh spent ~1 s asking the keychain
1380        // the same handful of questions dozens of times. `routing_registry_
1381        // snapshot` runs a refresh per request AND `estimated_tokens` runs
1382        // another, so a single delegated call paid it twice: ~2 s before the
1383        // request reached the runner, which is the reported latency.
1384        //
1385        // Deduplicating changes no semantics. Within ONE refresh the same env
1386        // var must yield the same answer — reading it 30 times cannot be more
1387        // correct than reading it once. Credentials are still re-read on every
1388        // refresh, so the "pasted/OAuth key changes take effect without a
1389        // restart" property above is untouched.
1390        let mut credential_envs: std::collections::BTreeSet<String> = Default::default();
1391        let mut needs_openrouter = false;
1392        for m in self.models.values() {
1393            match &m.source {
1394                ModelSource::RemoteApi {
1395                    protocol: crate::schema::ApiProtocol::OpenRouter,
1396                    ..
1397                } => needs_openrouter = true,
1398                ModelSource::RemoteApi { api_key_env, .. } => {
1399                    credential_envs.insert(api_key_env.clone());
1400                }
1401                ModelSource::Proprietary { auth, .. } => match auth {
1402                    ProprietaryAuth::ApiKeyEnv { env_var }
1403                    | ProprietaryAuth::BearerTokenEnv { env_var } => {
1404                        credential_envs.insert(env_var.clone());
1405                    }
1406                    ProprietaryAuth::OAuth2Pkce { .. } => {}
1407                },
1408                _ => {}
1409            }
1410        }
1411        let credential_available: std::collections::HashMap<String, bool> = credential_envs
1412            .into_iter()
1413            .map(|env| {
1414                let available = if authoritative_credentials {
1415                    car_secrets::resolve_env_or_keychain(&env).is_some()
1416                } else {
1417                    environment_credential_available(&env)
1418                };
1419                (env, available)
1420            })
1421            .collect();
1422        // Only probe OpenRouter when a row actually needs it — an unnecessary
1423        // probe is the same class of waste this block exists to remove.
1424        let openrouter_available = needs_openrouter
1425            && if authoritative_credentials {
1426                crate::openrouter::refresh_credential_source().is_some()
1427            } else {
1428                crate::openrouter::credential_source().is_some()
1429            };
1430
1431        for m in self.models.values_mut() {
1432            match &m.source {
1433                ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1434                    // MLX requires Apple Silicon Metal. On any other
1435                    // build target — Intel Mac, Linux, Windows, or
1436                    // `car_skip_mlx` — the backend is not compiled in
1437                    // and the adaptive router must not select these
1438                    // models. Cfg-gate identical to the
1439                    // AppleFoundationModels branch below and the
1440                    // `register()` MLX branch above. Closes the §7.1
1441                    // arm of Parslee-ai/car#231.
1442                    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1443                    {
1444                        // Models tagged `requires-mlx-vlm` shell out to the
1445                        // mlx_vlm Python CLI for image inference (#115).
1446                        // If the CLI isn't on PATH, the runtime reaches it
1447                        // anyway and bails — the registry MUST reflect that
1448                        // by marking such entries unavailable until the
1449                        // user installs `uv tool install mlx-vlm`. #137.
1450                        let needs_mlx_vlm = m.tags.iter().any(|t| t == "requires-mlx-vlm");
1451
1452                        m.available = if needs_mlx_vlm {
1453                            mlx_vlm_cli_present
1454                        } else if m.tags.contains(&"speech".to_string()) {
1455                            speech_mlx_available()
1456                        } else {
1457                            // Available if cached locally OR has an hf_repo —
1458                            // the native MLX path's ensure_local() lazy-
1459                            // downloads on first use, so a declared hf_repo
1460                            // is "functionally available" the same way
1461                            // Ollama and RemoteApi entries are ("should
1462                            // work in principle" not "physically cached").
1463                            // Closes #164: mlx/ltx-2.3:q4 reported
1464                            // unavailable even though `car video` would
1465                            // just download-and-run successfully.
1466                            let mlx_dir = models_dir.join(&m.name);
1467                            mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty()
1468                        };
1469                    }
1470                    #[cfg(not(all(
1471                        target_os = "macos",
1472                        target_arch = "aarch64",
1473                        not(car_skip_mlx)
1474                    )))]
1475                    {
1476                        let _ = hf_repo; // unused on non-MLX targets
1477                        m.available = false;
1478                    }
1479                }
1480                ModelSource::Local {
1481                    hf_repo: local_repo,
1482                    ..
1483                } => {
1484                    let local_path = models_dir.join(&m.name).join("model.gguf");
1485                    // Where the Candle GGUF backend exists — every target that
1486                    // is not Apple-Silicon-with-MLX, i.e. the CUDA machines
1487                    // where GGUF *is* the local path — a declared `hf_repo` is
1488                    // functionally available, because `ensure_local` fetches
1489                    // `model.gguf` and `tokenizer.json` from it on first use.
1490                    // Same rule #164 gave the MLX path and whisper.cpp already
1491                    // has; this branch was left behind, so a fresh CUDA install
1492                    // reported every local model unavailable, the router
1493                    // dropped them from fallback chains, and the user was sent
1494                    // to cloud with a GPU sitting idle one download away.
1495                    //
1496                    // An empty `hf_repo` means a row from the local-directory
1497                    // scan, which has nowhere to fetch from and stays gated on
1498                    // the file actually being there.
1499                    #[cfg(not(all(
1500                        target_os = "macos",
1501                        target_arch = "aarch64",
1502                        not(car_skip_mlx)
1503                    )))]
1504                    {
1505                        m.available = local_path.exists() || !local_repo.is_empty();
1506                    }
1507                    // On Apple Silicon `backend::candle` is not compiled at
1508                    // all: a GGUF row runs only by `resolve_mlx_equivalent`
1509                    // substitution, so it stays gated on physical presence
1510                    // rather than claiming a lazy download that has no loader.
1511                    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1512                    {
1513                        let _ = local_repo;
1514                        m.available = local_path.exists();
1515                    }
1516                }
1517                ModelSource::WhisperCpp { .. } => {
1518                    // whisper.cpp compiles on every platform and lazy-downloads
1519                    // its ggml model on first use (car-whisper), so it is
1520                    // functionally available everywhere — the cross-platform
1521                    // on-device STT the MLX speech models can't be off Apple.
1522                    m.available = true;
1523                }
1524                ModelSource::WindowsSpeech {} => {
1525                    // OS-provided WinRT synthesizer — available on Windows only
1526                    // (like AppleFoundationModels is Apple-only).
1527                    #[cfg(target_os = "windows")]
1528                    {
1529                        m.available = true;
1530                    }
1531                    #[cfg(not(target_os = "windows"))]
1532                    {
1533                        m.available = false;
1534                    }
1535                }
1536                ModelSource::RemoteApi {
1537                    protocol: crate::schema::ApiProtocol::OpenRouter,
1538                    ..
1539                } => {
1540                    m.available = openrouter_available;
1541                }
1542                ModelSource::RemoteApi { api_key_env, .. } => {
1543                    // env OR keychain — see `register`. Resolved once per
1544                    // refresh above, not once per model (car-releases#75).
1545                    m.available = credential_available
1546                        .get(api_key_env)
1547                        .copied()
1548                        .unwrap_or(false);
1549                }
1550                ModelSource::CodexCli { .. } => {
1551                    m.available = crate::backend::codex_cli::is_available();
1552                }
1553                ModelSource::Ollama { .. } => {
1554                    // Assume available; health check is async and done lazily
1555                    m.available = true;
1556                }
1557                ModelSource::VllmMlx { .. } => {
1558                    // External endpoints remain server-provided even on
1559                    // loopback. CAR's managed runtime says nothing about the
1560                    // health or ownership of this separately registered server.
1561                    m.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || m.available;
1562                }
1563                ModelSource::Proprietary { provider, auth, .. } => {
1564                    m.available = proprietary_auth_available(
1565                        &m.id,
1566                        &m.provider,
1567                        provider,
1568                        auth,
1569                        parslee_oauth_available,
1570                        &credential_available,
1571                    );
1572                }
1573                ModelSource::AppleFoundationModels { .. } => {
1574                    // Apple Silicon macOS 26+ AND iOS 26+ both expose
1575                    // the FoundationModels framework. The shim's
1576                    // runtime probe handles per-device availability
1577                    // (Apple Intelligence may be off, the device may
1578                    // be pre-A17, etc.); cfg-gating here just hides
1579                    // the call on targets where the shim isn't built.
1580                    #[cfg(any(
1581                        all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
1582                        all(target_os = "ios", target_arch = "aarch64")
1583                    ))]
1584                    {
1585                        m.available = crate::backend::foundation_models::is_available();
1586                    }
1587                    #[cfg(not(any(
1588                        all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
1589                        all(target_os = "ios", target_arch = "aarch64")
1590                    )))]
1591                    {
1592                        m.available = false;
1593                    }
1594                }
1595                ModelSource::Delegated { .. } => {
1596                    // Availability tracks whether a runner is registered.
1597                    // Hosts call `registerInferenceRunner` (or its
1598                    // language equivalent) at startup; until then the
1599                    // model is unavailable.
1600                    m.available = crate::runner::current_inference_runner().is_some();
1601                }
1602            }
1603            m.weights_ready = physical_weights_ready(m, &models_dir);
1604        }
1605    }
1606
1607    /// Persist only explicitly user-registered models to disk.
1608    pub fn save_user_config(&self) -> Result<(), InferenceError> {
1609        let mut user_models: Vec<ModelSchema> = self
1610            .user_config_ids
1611            .iter()
1612            .filter_map(|id| self.models.get(id))
1613            .cloned()
1614            .map(|mut model| {
1615                // `models.json` is a user-controlled trust boundary regardless
1616                // of how the in-memory row was originally registered.
1617                model.mark_user_registered();
1618                model
1619            })
1620            .collect();
1621        user_models.sort_by(|a, b| a.id.cmp(&b.id));
1622
1623        for model in &user_models {
1624            crate::catalog_identity::row_digest(model).map_err(|error| {
1625                InferenceError::InferenceFailed(format!(
1626                    "refuse to persist model without canonical catalog identity: {error}"
1627                ))
1628            })?;
1629        }
1630
1631        let json = serde_json::to_string_pretty(&user_models)
1632            .map_err(|e| InferenceError::InferenceFailed(format!("serialize: {e}")))?;
1633        std::fs::write(&self.user_config_path, json)?;
1634        Ok(())
1635    }
1636
1637    /// Load user-registered models from disk.
1638    pub fn load_user_config(&mut self) -> Result<(), InferenceError> {
1639        if !self.user_config_path.exists() {
1640            return Ok(());
1641        }
1642
1643        let json = std::fs::read_to_string(&self.user_config_path)?;
1644        let models: Vec<ModelSchema> = serde_json::from_str(&json)
1645            .map_err(|e| InferenceError::InferenceFailed(format!("parse models.json: {e}")))?;
1646
1647        for m in models {
1648            // Legacy rows omit `trust_tier` and serde must keep accepting that
1649            // shape, but a manual config can never confer project curation.
1650            self.register_user_model(m);
1651        }
1652        Ok(())
1653    }
1654
1655    /// Get the models directory path.
1656    pub fn models_dir(&self) -> &Path {
1657        &self.models_dir
1658    }
1659
1660    /// Whether invoking this model can start without CAR-managed model setup.
1661    ///
1662    /// `available` deliberately includes lazy-downloadable local models so
1663    /// normal CLI inference can pull on first use. Interactive host actions
1664    /// need the stricter answer: avoid starting a large download from a
1665    /// ready-to-use assistant button and point the user at Models setup
1666    /// instead.
1667    pub fn ready_without_download(&self, id: &str) -> Option<bool> {
1668        let schema = self.get(id).or_else(|| self.find_by_name(id))?;
1669        Some(match &schema.source {
1670            ModelSource::Local { .. } => {
1671                let model_dir = self.models_dir.join(&schema.name);
1672                crate::download::cache_file_usable(&model_dir.join("model.gguf"))
1673                    && crate::download::cache_file_usable(&model_dir.join("tokenizer.json"))
1674            }
1675            ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1676                let managed_dir = self.models_dir.join(&schema.name);
1677                let managed_ready = mlx_snapshot_complete(schema, &managed_dir)
1678                    && crate::download::cache_file_usable(&managed_dir.join("tokenizer.json"));
1679                let snapshot_ready =
1680                    latest_huggingface_repo_snapshot(hf_repo).is_some_and(|snapshot| {
1681                        mlx_snapshot_complete(schema, &snapshot)
1682                            && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
1683                    });
1684                managed_ready || snapshot_ready
1685            }
1686            ModelSource::WindowsSpeech {} => true, // OS-provided; nothing to download
1687            ModelSource::WhisperCpp { model } => {
1688                // "ready without download" = the ggml file is physically cached
1689                // at ~/.tokhn/whisper/. (Availability is looser — whisper always
1690                // lazy-downloads — but this is the strict host-action answer.)
1691                car_whisper::model_cached(model)
1692            }
1693            ModelSource::RemoteApi { .. }
1694            | ModelSource::CodexCli { .. }
1695            | ModelSource::Ollama { .. }
1696            | ModelSource::VllmMlx { .. }
1697            | ModelSource::AppleFoundationModels { .. }
1698            | ModelSource::Proprietary { .. }
1699            | ModelSource::Delegated { .. } => true,
1700        })
1701    }
1702
1703    /// Resolve a usable local artifact without downloading anything. This is
1704    /// the only source path accepted by explicit model adoption; callers do
1705    /// not get to supply a filesystem path.
1706    pub fn existing_local_artifact(&self, id: &str) -> Option<PathBuf> {
1707        let schema = self.get(id).or_else(|| self.find_by_name(id))?;
1708        let managed = self.models_dir.join(&schema.name);
1709        if std::fs::symlink_metadata(&managed).is_ok()
1710            && self.ready_without_download(&schema.id) == Some(true)
1711        {
1712            return Some(managed);
1713        }
1714        match &schema.source {
1715            ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1716                latest_huggingface_repo_snapshot(hf_repo).filter(|snapshot| {
1717                    mlx_snapshot_complete(schema, snapshot)
1718                        && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
1719                })
1720            }
1721            _ => None,
1722        }
1723    }
1724
1725    /// Load the built-in Qwen3 catalog as ModelSchema objects.
1726    fn load_builtin_catalog(&mut self) {
1727        for schema in builtin_catalog() {
1728            let id = schema.id.clone();
1729            if self.register_project_model(schema) {
1730                self.builtin_model_ids.insert(id);
1731            }
1732        }
1733    }
1734}
1735
1736/// Build a `ModelSchema` for an uncatalogued local model directory, or
1737/// `None` if the directory isn't a recognized text-LLM checkpoint.
1738///
1739/// Recognizes two layouts: MLX (a `config.json` with a known causal-LM
1740/// `model_type` plus safetensors weights) and GGUF (a `*.gguf` weight
1741/// file). Capabilities are inferred conservatively from the directory
1742/// name — anything not positively identified as a text LLM is skipped so
1743/// speech/vision/image/video checkpoints are never mistaken for
1744/// generators. car-releases#62.
1745fn synthesize_local_schema(name: &str, dir: &Path) -> Option<ModelSchema> {
1746    let lower = name.to_ascii_lowercase();
1747
1748    // Hard skip directory names that are unambiguously non-text models —
1749    // these would be catastrophic to route as `Generate`.
1750    const NON_TEXT_HINTS: &[&str] = &[
1751        "vad",
1752        "whisper",
1753        "parakeet",
1754        "kokoro",
1755        "tts",
1756        "stt",
1757        "flux",
1758        "ltx",
1759        "yume",
1760        "sd-",
1761        "stable-diffusion",
1762        "wan",
1763        "mochi",
1764        "sana",
1765        "diffusion",
1766    ];
1767    if NON_TEXT_HINTS.iter().any(|h| lower.contains(h)) {
1768        return None;
1769    }
1770
1771    // Capabilities by name. Embedding / reranker checkpoints share the same
1772    // causal-LM architecture as generators, so the name is the only signal.
1773    let capabilities: Vec<ModelCapability> =
1774        if lower.contains("embedding") || lower.contains("embed") {
1775            vec![ModelCapability::Embed]
1776        } else if lower.contains("reranker") || lower.contains("rerank") {
1777            vec![ModelCapability::Rerank]
1778        } else {
1779            vec![
1780                ModelCapability::Generate,
1781                ModelCapability::Code,
1782                ModelCapability::Reasoning,
1783            ]
1784        };
1785
1786    // Locate weights and (for MLX) validate the architecture.
1787    let config_path = dir.join("config.json");
1788    let has_safetensors =
1789        dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists();
1790
1791    let (source, context_length, quantization) = if config_path.exists() && has_safetensors {
1792        // MLX layout. Only accept a recognized causal-LM model_type.
1793        let cfg: serde_json::Value = std::fs::read_to_string(&config_path)
1794            .ok()
1795            .and_then(|s| serde_json::from_str(&s).ok())?;
1796        let model_type = cfg
1797            .get("model_type")
1798            .and_then(|v| v.as_str())
1799            .unwrap_or("")
1800            .to_ascii_lowercase();
1801        const KNOWN_LLM_TYPES: &[&str] = &[
1802            "qwen",
1803            "qwen2",
1804            "qwen3",
1805            "qwen3_moe",
1806            "llama",
1807            "mistral",
1808            "mixtral",
1809            "gemma",
1810            "gemma2",
1811            "gemma3",
1812            "gemma4_unified",
1813            "gemma4_unified_text",
1814            "phi",
1815            "phi3",
1816            "phimoe",
1817            "starcoder2",
1818            "deepseek",
1819            "deepseek_v2",
1820            "internlm2",
1821            "cohere",
1822            "olmo",
1823        ];
1824        if !KNOWN_LLM_TYPES.iter().any(|t| model_type == *t) {
1825            return None;
1826        }
1827        let ctx = cfg
1828            .get("max_position_embeddings")
1829            .and_then(|v| v.as_u64())
1830            .unwrap_or(32_768) as usize;
1831        // Same block `hf_schema::quantization_of` reads, and for the same
1832        // reason: `bits` alone cannot tell an affine group quant from an MX
1833        // float, and the group size is what a kernel has to match. This used
1834        // to emit `"4-bit"` while the other ingest path emitted `"4bit"` —
1835        // two spellings of one thing, in a field nothing could compare.
1836        let quant = cfg
1837            .get("quantization")
1838            .filter(|q| q.is_object())
1839            .and_then(|q| {
1840                let bits = q
1841                    .get("bits")
1842                    .and_then(|b| b.as_u64())
1843                    .and_then(|b| u8::try_from(b).ok());
1844                let group_size = q
1845                    .get("group_size")
1846                    .and_then(|g| g.as_u64())
1847                    .and_then(|g| u32::try_from(g).ok());
1848                let mode = q.get("mode").and_then(|m| m.as_str());
1849                crate::schema::Quantization::from_mlx_config(bits, group_size, mode)
1850            });
1851        (
1852            serde_json::json!({ "type": "mlx", "hf_repo": "" }),
1853            ctx,
1854            quant,
1855        )
1856    } else {
1857        let gguf = std::fs::read_dir(dir).ok().and_then(|rd| {
1858            rd.flatten().map(|e| e.path()).find(|p| {
1859                p.extension()
1860                    .and_then(|x| x.to_str())
1861                    .is_some_and(|x| x.eq_ignore_ascii_case("gguf"))
1862            })
1863        })?;
1864        // GGUF layout (Candle). file name carried for the Local source.
1865        let filename = gguf
1866            .file_name()
1867            .and_then(|n| n.to_str())
1868            .unwrap_or("model.gguf")
1869            .to_string();
1870        // A GGUF file names its own quantization and this path used to throw
1871        // that away, so a user's own local checkpoints were the one ingest
1872        // route that recorded nothing at all.
1873        let quant = crate::schema::Quantization::from_gguf_filename(&filename);
1874        (
1875            serde_json::json!({
1876                "type": "local",
1877                "hf_repo": "",
1878                "hf_filename": filename,
1879                "tokenizer_repo": "",
1880            }),
1881            4_096,
1882            quant,
1883        )
1884    };
1885
1886    let id = format!("local/{}", lower.replace(['/', ' '], "-"));
1887    serde_json::from_value(serde_json::json!({
1888        "id": id,
1889        "name": name,
1890        "provider": "local",
1891        "family": "local",
1892        "capabilities": capabilities,
1893        "context_length": context_length,
1894        "quantization": quantization,
1895        "source": source,
1896        "tags": ["auto-discovered"],
1897        "trust_tier": "community",
1898    }))
1899    .ok()
1900}
1901
1902#[allow(dead_code)] // callers are Apple-Silicon/MLX-gated; dead in car_skip_mlx builds
1903fn speech_mlx_available() -> bool {
1904    // On Apple Silicon, speech uses native MLX backends — no Python CLI needed.
1905    // Models are available if we're on the right platform (weights are downloaded on demand).
1906    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1907    {
1908        true
1909    }
1910
1911    // On other platforms, check for the Python mlx-audio CLI.
1912    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1913    {
1914        // Via `venv_program`, so this agrees with `SpeechRuntime` about where a
1915        // provisioned runtime keeps its console scripts — `Scripts\*.exe` on
1916        // Windows, `bin/*` elsewhere. This branch is the non-Apple-Silicon one,
1917        // so Windows is exactly who reaches it.
1918        let runtime_root = speech_runtime_root();
1919        crate::managed_venv::venv_program(&runtime_root, "mlx_audio.stt.generate").exists()
1920            || crate::managed_venv::venv_program(&runtime_root, "mlx_audio.tts.generate").exists()
1921    }
1922}
1923
1924#[allow(dead_code)] // general (no MLX deps); called from speech_mlx_available's non-MLX branch
1925fn speech_runtime_root() -> PathBuf {
1926    if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
1927        if !path.trim().is_empty() {
1928            return PathBuf::from(path);
1929        }
1930    }
1931    std::env::var_os("HOME")
1932        .or_else(|| std::env::var_os("USERPROFILE"))
1933        .map(PathBuf::from)
1934        .unwrap_or_else(|| PathBuf::from("."))
1935        .join(".car")
1936        .join("speech-runtime")
1937}
1938
1939/// Backward-compatible ModelInfo for listing (used by CLI and old callers).
1940#[derive(Debug, Clone, Serialize, Deserialize)]
1941pub struct ModelInfo {
1942    pub id: String,
1943    pub name: String,
1944    pub provider: String,
1945    pub capabilities: Vec<ModelCapability>,
1946    pub param_count: String,
1947    pub size_mb: u64,
1948    pub context_length: usize,
1949    pub available: bool,
1950    pub is_local: bool,
1951    /// True only for a raw `VllmMlx` source whose runtime is operated outside
1952    /// CAR. The endpoint may be loopback or remote; its address is not
1953    /// ownership evidence. CAR-owned `ManagedVllmMlx` remains a local row.
1954    #[serde(default)]
1955    pub operator_managed_external_runtime: bool,
1956    /// Weights resolvable on disk *now* — i.e. usable without a download.
1957    /// Distinct from `available`, which for a lazy-downloading MLX entry is
1958    /// true before a byte is fetched (#164). Remote models need no weights
1959    /// and report `true`. See `ModelSchema::weights_ready` (#638).
1960    ///
1961    /// `#[serde(default)]` so a client built against this version can still
1962    /// parse a catalog from a daemon that predates the field.
1963    #[serde(default)]
1964    pub weights_ready: bool,
1965    /// Whether CAR fetches weights to disk for this entry at all — i.e.
1966    /// whether `weights_ready` is a question with a meaningful answer.
1967    ///
1968    /// `false` for OS-provided models (`windows/speech-synthesis:os`,
1969    /// `apple/foundation:default`), for server-backed local models
1970    /// (`vllm-mlx/*`, Ollama) and for every remote entry. `is_local` is NOT a
1971    /// substitute: the first four of those are local and still download
1972    /// nothing. Consumers should render no install status when this is
1973    /// `false`. See `ModelSchema::downloads_weights` (#894).
1974    ///
1975    /// `#[serde(default)]` so a client built against this version can still
1976    /// parse a catalog from a daemon that predates the field.
1977    #[serde(default)]
1978    pub downloads_weights: bool,
1979    /// Per-model maximum OUTPUT tokens (registry-declared). `None` when the
1980    /// catalog entry omits it; callers fall back to a fraction of
1981    /// `context_length` via `ModelSchema::effective_max_output()`.
1982    #[serde(default)]
1983    pub max_output_tokens: Option<usize>,
1984    /// Public benchmark scores carried straight through from `ModelSchema`.
1985    /// The built-in catalog ships this empty; populating it is a curation
1986    /// step (see `BenchmarkScore` in the schema for shape and conventions).
1987    #[serde(default)]
1988    pub public_benchmarks: Vec<crate::schema::BenchmarkScore>,
1989    /// Declared per-token prices carried straight through from
1990    /// `ModelSchema::cost` — USD per 1M tokens for uncached input, output,
1991    /// cache reads and cache writes, plus any prompt-size pricing tiers the
1992    /// catalog declares.
1993    ///
1994    /// Every price component is `Option`: a local model that declares no
1995    /// prices reports them as `null`, which is deliberately **not** the same
1996    /// as a declared `0.0`. Consumers that price a request must distinguish
1997    /// "free" from "unpriced" — collapsing the two is how a caller ends up
1998    /// publishing a fabricated $0.00.
1999    ///
2000    /// `#[serde(default)]` so a newly built client can parse a catalog from
2001    /// an older daemon that predates this field: the row deserializes with an
2002    /// all-`None` cost rather than failing the whole response.
2003    #[serde(default)]
2004    pub cost: crate::schema::CostModel,
2005    #[serde(default = "default_true")]
2006    pub car_enabled: bool,
2007    #[serde(default)]
2008    pub can_remove: bool,
2009    #[serde(default)]
2010    pub in_use: bool,
2011    #[serde(default)]
2012    pub management_evidence: Option<String>,
2013    /// Whether this row fits the machine that answered, judged by the same
2014    /// rule `models.recommend` partitions on: the cold-load estimate at the
2015    /// recommendation context against the active resource policy's ceiling.
2016    /// `fits` for remote, delegated and operator-managed rows, whose memory is
2017    /// not this machine's. Computed per request from `HardwareInfo::detect()`
2018    /// and the engine's active policy; never persisted.
2019    ///
2020    /// `#[serde(default)]` (→ `unknown`) so a client built against this
2021    /// version can parse a catalog from a daemon that predates the field, and
2022    /// so an absent verdict never reads as too-big.
2023    #[serde(default)]
2024    pub fit: crate::recommend::ModelFitStatus,
2025    /// The cold-load peak `fit` was judged against, in MB. `null` when the
2026    /// row's memory is not this machine's or it declares no size/RAM figure.
2027    #[serde(default)]
2028    pub estimated_peak_mb: Option<u64>,
2029    /// Whether this machine's accelerator can run the row at all — `false`
2030    /// for a Metal-only row (MLX, managed vLLM-MLX, Apple FoundationModels)
2031    /// on a non-Apple machine, the rows `models.recommend` never offers.
2032    /// Defaults to `true` for the same older-daemon reason as `fit`.
2033    #[serde(default = "default_true")]
2034    pub platform_compatible: bool,
2035    /// Carried through from `ModelSchema::deprecated`: superseded in the
2036    /// catalog. The row stays listed (an installed deprecated model is still
2037    /// usable); clients lead with current models by sorting or hiding on it.
2038    #[serde(default)]
2039    pub deprecated: bool,
2040    /// Model line for grouping (`qwen3`, `gemma-4`), published for local rows
2041    /// only. Remote rows report `null` because this response deliberately
2042    /// carries no upstream identifier for the managed `parslee/openrouter/*`
2043    /// aliases, whose `family` names the upstream model; `models.search`
2044    /// publishes it for every row.
2045    #[serde(default)]
2046    pub family: Option<String>,
2047    /// Version or checkpoint label within the family, local rows only for
2048    /// the same reason as `family` (an alias's `version` is the upstream
2049    /// snapshot).
2050    #[serde(default)]
2051    pub version: Option<String>,
2052}
2053
2054fn default_true() -> bool {
2055    true
2056}
2057
2058impl ModelInfo {
2059    /// Stamp a computed fit annotation onto the row. Kept as one call so no
2060    /// caller can publish `fit` without the estimate and platform flag it
2061    /// was judged with.
2062    pub fn with_fit(mut self, fit: crate::recommend::ModelFit) -> Self {
2063        self.fit = fit.fit;
2064        self.estimated_peak_mb = fit.estimated_peak_mb;
2065        self.platform_compatible = fit.platform_compatible;
2066        self
2067    }
2068}
2069
2070impl From<&ModelSchema> for ModelInfo {
2071    fn from(s: &ModelSchema) -> Self {
2072        ModelInfo {
2073            id: s.id.clone(),
2074            name: s.name.clone(),
2075            provider: s.provider.clone(),
2076            capabilities: s.capabilities.clone(),
2077            param_count: s.param_count.clone(),
2078            size_mb: s.size_mb(),
2079            context_length: s.context_length,
2080            available: s.available_now(),
2081            is_local: s.is_local(),
2082            operator_managed_external_runtime: matches!(s.source, ModelSource::VllmMlx { .. }),
2083            weights_ready: s.weights_ready,
2084            downloads_weights: s.downloads_weights(),
2085            max_output_tokens: s.max_output_tokens,
2086            public_benchmarks: s.public_benchmarks.clone(),
2087            // Prices only — the managed `parslee/…` aliases carry the same
2088            // `CostModel` as the personal row they front, and a `CostModel`
2089            // holds no identifiers, so publishing it discloses nothing about
2090            // the upstream model id.
2091            cost: s.cost.clone(),
2092            car_enabled: true,
2093            can_remove: false,
2094            in_use: false,
2095            management_evidence: None,
2096            // Not a verdict: the machine and policy are the engine's to
2097            // supply. `list_models_unified` stamps the real annotation with
2098            // `with_fit`; an unannotated projection reads as "unknown", the
2099            // value that hides nothing.
2100            fit: crate::recommend::ModelFitStatus::Unknown,
2101            estimated_peak_mb: None,
2102            platform_compatible: true,
2103            deprecated: s.deprecated,
2104            // Local rows only — see the field docs for the non-disclosure
2105            // rule on managed aliases.
2106            family: s.is_local().then(|| s.family.clone()),
2107            version: s.is_local().then(|| s.version.clone()),
2108        }
2109    }
2110}
2111
2112/// Emit a lightweight `FileStarted` marker for an MLX auxiliary/weight file.
2113/// MLX pulls don't know `total_files` up front (single vs sharded), so these
2114/// carry `total_files = 0` ("unknown") and the UI shows the file name. The
2115/// sharded weight loop, which knows its count, pairs Started/Completed itself.
2116fn emit_file(sink: &ProgressSink, name: &str, index: u32, size_mb: u64) {
2117    sink.emit(DownloadEvent::FileStarted {
2118        filename: name.to_string(),
2119        index,
2120        total_files: 0,
2121        size_mb,
2122    });
2123}
2124
2125/// Download a single file from a HuggingFace repo.
2126/// Mirror every file in a HuggingFace repo into `model_dir`, preserving its
2127/// subdirectory structure. For diffusers-layout models (Flux/LTX) whose weights
2128/// live in component subdirs (`transformer/`, `vae/`, `text_encoder/`, …) with
2129/// no root `config.json` or top-level weight index — the file-by-file MLX flow
2130/// can't enumerate them, so the whole snapshot is fetched. The repo file list
2131/// comes from the HF models API (version-independent, unlike hf-hub's `info()`);
2132/// metadata (`.gitattributes`, dotfiles, markdown) is skipped.
2133async fn download_repo_snapshot(
2134    repo: &str,
2135    model_dir: &Path,
2136    sink: &ProgressSink,
2137) -> Result<(), InferenceError> {
2138    #[derive(serde::Deserialize)]
2139    struct RepoInfo {
2140        siblings: Vec<Sibling>,
2141    }
2142    #[derive(serde::Deserialize)]
2143    struct Sibling {
2144        rfilename: String,
2145    }
2146    let url = format!("https://huggingface.co/api/models/{repo}");
2147    // Not `Client::new()`: that is `build().expect(..)`, which panics when the
2148    // OS trust store loads zero valid certificates — the failure the ladder in
2149    // `tls_client` exists to survive. huggingface.co is a public endpoint, so
2150    // the public-CA rung serves this call fully.
2151    let info: RepoInfo = crate::tls_client::model_download_client()
2152        .get(&url)
2153        .send()
2154        .await
2155        .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2156        .error_for_status()
2157        .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2158        .json()
2159        .await
2160        .map_err(|e| InferenceError::DownloadFailed(format!("parse {repo} file list: {e}")))?;
2161
2162    let files: Vec<String> = info
2163        .siblings
2164        .into_iter()
2165        .map(|s| s.rfilename)
2166        .filter(|f| !f.starts_with('.') && !f.to_ascii_lowercase().ends_with(".md"))
2167        .collect();
2168    if files.is_empty() {
2169        return Err(InferenceError::DownloadFailed(format!(
2170            "{repo}: repo lists no downloadable files"
2171        )));
2172    }
2173
2174    let total = files.len() as u32;
2175    for (i, fname) in files.iter().enumerate() {
2176        let dest = model_dir.join(fname);
2177        if crate::download::cache_file_usable(&dest) {
2178            continue;
2179        }
2180        if let Some(parent) = dest.parent() {
2181            std::fs::create_dir_all(parent)?;
2182        }
2183        sink.emit(DownloadEvent::FileStarted {
2184            filename: fname.clone(),
2185            index: (i + 1) as u32,
2186            total_files: total,
2187            size_mb: 0,
2188        });
2189        download_file(repo, fname, &dest).await?;
2190        sink.emit(DownloadEvent::FileCompleted {
2191            filename: fname.clone(),
2192        });
2193    }
2194    Ok(())
2195}
2196
2197/// Tokenizer filenames seen across the repos CAR pulls, in preference order.
2198///
2199/// There is no single convention, which is the whole point of this list:
2200///
2201/// | Layout | Files | Example |
2202/// |---|---|---|
2203/// | `tokenizers` library | `tokenizer.json` | most modern text MLX repos |
2204/// | GPT-2 BPE pair | `vocab.json`, `merges.txt` | `Qwen3-TTS-12Hz-1.7B-Base-5bit` |
2205/// | SentencePiece | `tokenizer.model`, `tokenizer.vocab`, `vocab.txt` | `parakeet-tdt-0.6b-v3` |
2206/// | none at all | — | `Kokoro-82M-*` (phoneme-based, needs no tokenizer) |
2207const TOKENIZER_FILENAMES: &[&str] = &[
2208    "tokenizer.json",
2209    "vocab.json",
2210    "merges.txt",
2211    "tokenizer.model",
2212    "tokenizer.vocab",
2213    "vocab.txt",
2214];
2215
2216/// Fetch whatever tokenizer assets a repo actually has. **Never fails the pull
2217/// over their absence.**
2218///
2219/// This used to be a single `download_file(repo, "tokenizer.json", …).await?` —
2220/// an unconditional hard requirement that aborted the entire multi-gigabyte
2221/// acquisition when the file 404'd. That made every curated speech model
2222/// impossible to install, in either direction: both Kokoro TTS builds, the
2223/// Qwen3-TTS default, and the Parakeet STT default, so local speech did not work
2224/// at all on a clean machine (Parslee-ai/car#639).
2225///
2226/// Best-effort is the correct posture here, not a workaround. The downloader
2227/// cannot know what a given backend needs — and Kokoro settles it: it ships
2228/// **no** tokenizer files whatsoever, because a phoneme-based TTS has no use for
2229/// one. Any rule of the form "a tokenizer must be present" is therefore wrong
2230/// for some model CAR legitimately supports. A loader that genuinely needs a
2231/// tokenizer will fail at load with a precise, model-specific error, which beats
2232/// a blanket download-time abort that names a file the model was never going to
2233/// have. This also matches the treatment `tokenizer_config.json` already gets
2234/// on the very next line.
2235async fn download_tokenizer_assets(hf_repo: &str, model_dir: &Path, sink: &ProgressSink) {
2236    // Already satisfied by any recognized layout? Then don't re-probe the API.
2237    if TOKENIZER_FILENAMES
2238        .iter()
2239        .any(|f| crate::download::cache_file_usable(&model_dir.join(f)))
2240    {
2241        return;
2242    }
2243    emit_file(sink, "tokenizer", 0, 0);
2244    let mut fetched: Vec<&str> = Vec::new();
2245    for name in TOKENIZER_FILENAMES {
2246        let dest = model_dir.join(name);
2247        if crate::download::cache_file_usable(&dest) {
2248            continue;
2249        }
2250        if download_file(hf_repo, name, &dest).await.is_ok() {
2251            fetched.push(name);
2252        }
2253    }
2254    if fetched.is_empty() {
2255        // Not an error: see the doc comment — Kokoro has none by design.
2256        tracing::debug!(
2257            repo = %hf_repo,
2258            "no tokenizer assets in this repo; continuing (the backend may not need one)"
2259        );
2260    } else {
2261        tracing::debug!(repo = %hf_repo, files = ?fetched, "fetched tokenizer assets");
2262    }
2263}
2264
2265async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
2266    let api = hf_hub::api::tokio::Api::new()
2267        .map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
2268
2269    let repo = api.model(repo.to_string());
2270    let path = repo
2271        .get(filename)
2272        .await
2273        .map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
2274
2275    if dest.exists() {
2276        return Ok(());
2277    }
2278
2279    // Try symlink first, fall back to copy
2280    #[cfg(unix)]
2281    {
2282        if std::os::unix::fs::symlink(&path, dest).is_ok() {
2283            return Ok(());
2284        }
2285    }
2286
2287    std::fs::copy(&path, dest)
2288        .map_err(|e| InferenceError::DownloadFailed(format!("copy to {}: {e}", dest.display())))?;
2289    Ok(())
2290}
2291
2292async fn ensure_auxiliary_mlx_files(
2293    model_name: &str,
2294    hf_repo: &str,
2295    model_dir: &Path,
2296) -> Result<(), InferenceError> {
2297    if hf_repo == "mlx-community/Flux-1.lite-8B-MLX-Q4" || model_name == "Flux-1.lite-8B-MLX-Q4" {
2298        let t5_tokenizer_path = model_dir.join("tokenizer_2").join("tokenizer.json");
2299        if !t5_tokenizer_path.exists() {
2300            std::fs::create_dir_all(t5_tokenizer_path.parent().ok_or_else(|| {
2301                InferenceError::InferenceFailed("invalid tokenizer path".into())
2302            })?)?;
2303            info!(
2304                path = %t5_tokenizer_path.display(),
2305                "downloading missing Flux tokenizer_2/tokenizer.json from base model"
2306            );
2307            download_file(
2308                "Freepik/flux.1-lite-8B",
2309                "tokenizer_2/tokenizer.json",
2310                &t5_tokenizer_path,
2311            )
2312            .await?;
2313        }
2314    }
2315    Ok(())
2316}
2317
2318fn mlx_auxiliary_ready_without_download(model_name: &str, model_dir: &Path) -> bool {
2319    if model_name == "Flux-1.lite-8B-MLX-Q4" {
2320        return crate::download::cache_file_usable(
2321            &model_dir.join("tokenizer_2").join("tokenizer.json"),
2322        );
2323    }
2324    true
2325}
2326
2327/// The single physical-readiness projection used by registration, refresh,
2328/// recommendation/setup state, and ready-without-download checks.
2329fn physical_weights_ready(schema: &ModelSchema, models_dir: &Path) -> bool {
2330    physical_weights_ready_with_huggingface_hub(schema, models_dir, None)
2331}
2332
2333pub(crate) fn physical_weights_ready_with_huggingface_hub(
2334    schema: &ModelSchema,
2335    models_dir: &Path,
2336    huggingface_hub_root: Option<&Path>,
2337) -> bool {
2338    match &schema.source {
2339        ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2340            let managed_dir = models_dir.join(&schema.name);
2341            if mlx_snapshot_complete(schema, &managed_dir) {
2342                return true;
2343            }
2344            let shared_snapshot = match huggingface_hub_root {
2345                Some(root) => latest_huggingface_repo_snapshot_in(
2346                    &root.join(format!("models--{}", hf_repo.replace('/', "--"))),
2347                ),
2348                None => latest_huggingface_repo_snapshot(hf_repo),
2349            };
2350            shared_snapshot
2351                .as_deref()
2352                .is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
2353        }
2354        ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
2355        ModelSource::Local { .. } => {
2356            crate::download::cache_file_usable(&models_dir.join(&schema.name).join("model.gguf"))
2357        }
2358        // These sources have no CAR-downloaded artifact. `weights_ready` is a
2359        // readiness sentinel for legacy routing; callers must gate install
2360        // language with `downloads_weights()`.
2361        ModelSource::WindowsSpeech {}
2362        | ModelSource::AppleFoundationModels { .. }
2363        | ModelSource::VllmMlx { .. }
2364        | ModelSource::Ollama { .. }
2365        | ModelSource::RemoteApi { .. }
2366        | ModelSource::CodexCli { .. }
2367        | ModelSource::Proprietary { .. }
2368        | ModelSource::Delegated { .. } => true,
2369    }
2370}
2371
2372#[cfg(test)]
2373fn mlx_weights_ready_at(
2374    schema: &ModelSchema,
2375    managed_dir: &Path,
2376    shared_snapshot: Option<&Path>,
2377) -> bool {
2378    mlx_snapshot_complete(schema, managed_dir)
2379        || shared_snapshot.is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
2380}
2381
2382/// A complete MLX location, whether CAR's managed directory or a shared
2383/// Hugging Face snapshot. Standard text models need config + all indexed
2384/// shards. Diffusers layouts have no root config, so their recursively
2385/// validated component weights are the completeness authority. Tokenizer
2386/// readiness is a stricter invocation concern handled by
2387/// `ready_without_download`; `weights_ready` remains literal physical weights.
2388fn mlx_snapshot_complete(schema: &ModelSchema, dir: &Path) -> bool {
2389    let is_diffusers = schema.capabilities.iter().any(|capability| {
2390        matches!(
2391            capability,
2392            ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
2393        )
2394    });
2395    let metadata_ready =
2396        is_diffusers || crate::download::cache_file_usable(&dir.join("config.json"));
2397
2398    metadata_ready
2399        && mlx_dir_has_weights(dir)
2400        && mlx_auxiliary_ready_without_download(&schema.name, dir)
2401}
2402
2403/// Does a managed MLX model dir actually contain resolvable weights?
2404///
2405/// A dir holding only `config.json`/`tokenizer.json` stubs — or a dangling
2406/// `*.safetensors` symlink into a pruned HuggingFace cache — is NOT a complete
2407/// install. Treating it as installed makes `ensure_local`/`car models pull`
2408/// no-op and inference then fails at load with "no safetensors weights found"
2409/// (car-releases#391). We check for at least one `*.safetensors` whose target
2410/// resolves: `Path::exists()` follows symlinks, so a dangling link counts as
2411/// absent. This covers single-file (`model.safetensors`) and sharded
2412/// (`model-00001-of-0000N.safetensors`) layouts; the bare
2413/// `model.safetensors.index.json` (extension `.json`) correctly doesn't count.
2414///
2415/// **Sharded models are checked against their index, not by existence of any
2416/// one shard.** "At least one `*.safetensors` resolves" is the right question
2417/// for a single-file model and the wrong one for a sharded install: an
2418/// interrupted download that landed shard 2 of 2 satisfies it, so
2419/// `ensure_local` returns early without repairing, `car models pull` reports
2420/// success having done nothing, and the failure surfaces only at load —
2421/// `load model-00001-of-00002.safetensors: Path must point to a local file`
2422/// (Parslee-ai/car#808). The index enumerates every required shard, so when it
2423/// is present it is authoritative and cheap to consult.
2424pub(crate) fn mlx_dir_has_weights(dir: &Path) -> bool {
2425    // An index present at the top level means a sharded layout: require EVERY
2426    // shard it names. Absent (single-file, or a diffusers-layout model whose
2427    // components live in subdirs), fall back to the recursive any-weights walk.
2428    let index = dir.join("model.safetensors.index.json");
2429    if index.is_file() {
2430        return sharded_weight_files(&index).is_some_and(|required| {
2431            !required.is_empty()
2432                && required
2433                    .iter()
2434                    .all(|shard| crate::download::cache_file_usable(&dir.join(shard)))
2435        });
2436    }
2437    mlx_dir_has_weights_depth(dir, 0)
2438}
2439
2440/// Weight shards the index requires but that are not present on disk (#808).
2441///
2442/// Empty means "no shard filename could be reported" — including for
2443/// single-file/diffusers layouts and malformed indexes. Callers must pair this
2444/// with [`mlx_dir_has_weights`]; an empty diagnostic is never proof that the
2445/// model is complete.
2446///
2447/// Exists so a failed pull can say *which* shard is absent. The reported case
2448/// (#808) left a 6.7 GB model with one of two shards on disk for weeks, and the
2449/// only symptom was an inference-time `Path must point to a local file` naming
2450/// a shard the caller had no reason to know about.
2451pub(crate) fn missing_weight_shards(dir: &Path) -> Vec<String> {
2452    let index = dir.join("model.safetensors.index.json");
2453    if !index.is_file() {
2454        return Vec::new();
2455    }
2456    let Some(required) = sharded_weight_files(&index) else {
2457        // Unreadable index: there is no trustworthy shard filename to report.
2458        // Readiness still fails closed in `mlx_dir_has_weights`.
2459        return Vec::new();
2460    };
2461    required
2462        .into_iter()
2463        .filter(|shard| !dir.join(shard).exists())
2464        .collect()
2465}
2466
2467/// Shard filenames an MLX/HF `model.safetensors.index.json` says are required.
2468///
2469/// `weight_map` maps every tensor name to the file holding it, so the distinct
2470/// values are exactly the shard set. Returns `None` when the index cannot be
2471/// read or has no `weight_map`; readiness callers must fail closed when an
2472/// index exists but this metadata cannot be validated.
2473fn sharded_weight_files(index: &Path) -> Option<Vec<String>> {
2474    let raw = std::fs::read_to_string(index).ok()?;
2475    let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
2476    let map = parsed.get("weight_map")?.as_object()?;
2477    let mut files: Vec<String> = map
2478        .values()
2479        .filter_map(|v| v.as_str().map(str::to_string))
2480        .collect();
2481    files.sort();
2482    files.dedup();
2483    Some(files)
2484}
2485
2486/// Recurse into component subdirs to find weights, but bounded. Diffusers-layout
2487/// models (Flux image, LTX video, …) keep their component weights in subdirs
2488/// (`transformer/`, `vae/`, `text_encoder/`, …), not at the top level, so a flat
2489/// scan misjudges a fully-cached snapshot as weightless (the miss that sent the
2490/// image/video path into a re-download that 404s on the `config.json` these
2491/// repos don't ship). `load_all_tensors` reads those component safetensors
2492/// recursively. The walk is depth-capped and skips symlinked directories so a
2493/// symlink cycle in a cache dir can't stack-overflow us — the HF cache nests
2494/// real component dirs at most ~2 deep, with only leaf blob files symlinked
2495/// (those are files, resolved by `cache_file_usable`, not followed here).
2496fn mlx_dir_has_weights_depth(dir: &Path, depth: usize) -> bool {
2497    if depth > 4 {
2498        return false;
2499    }
2500    let Ok(rd) = std::fs::read_dir(dir) else {
2501        return false;
2502    };
2503    rd.flatten().any(|e| {
2504        let p = e.path();
2505        let is_symlink = std::fs::symlink_metadata(&p)
2506            .map(|m| m.file_type().is_symlink())
2507            .unwrap_or(true);
2508        if p.is_dir() {
2509            !is_symlink && mlx_dir_has_weights_depth(&p, depth + 1)
2510        } else {
2511            // `cache_file_usable` follows the symlink and requires non-empty, so
2512            // a dangling weight symlink (pruned blob) or a zero-length partial
2513            // write does not count as installed.
2514            p.extension().and_then(|x| x.to_str()) == Some("safetensors")
2515                && crate::download::cache_file_usable(&p)
2516        }
2517    })
2518}
2519
2520#[allow(dead_code)] // conditionally compiled — used only on MLX-backend (macOS) snapshot-resolution paths
2521fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
2522    latest_huggingface_repo_snapshot(repo_id).is_some()
2523}
2524
2525pub(crate) fn huggingface_cache_root() -> PathBuf {
2526    std::env::var("HF_HOME")
2527        .map(PathBuf::from)
2528        .unwrap_or_else(|_| {
2529            std::env::var_os("HOME")
2530                .or_else(|| std::env::var_os("USERPROFILE"))
2531                .map(PathBuf::from)
2532                .unwrap_or_else(|| PathBuf::from("."))
2533                .join(".cache")
2534                .join("huggingface")
2535        })
2536        .join("hub")
2537}
2538
2539pub(crate) fn huggingface_repo_dir(repo_id: &str) -> PathBuf {
2540    huggingface_cache_root().join(format!("models--{}", repo_id.replace('/', "--")))
2541}
2542
2543fn resolve_huggingface_ref_snapshot(repo_dir: &Path, name: &str) -> Option<PathBuf> {
2544    let sha = std::fs::read_to_string(repo_dir.join("refs").join(name))
2545        .ok()?
2546        .trim()
2547        .to_string();
2548    if sha.is_empty() {
2549        return None;
2550    }
2551
2552    let snapshot = repo_dir.join("snapshots").join(sha);
2553    if snapshot_looks_ready(&snapshot) {
2554        Some(snapshot)
2555    } else {
2556        None
2557    }
2558}
2559
2560fn latest_huggingface_repo_snapshot(repo_id: &str) -> Option<PathBuf> {
2561    let repo_dir = huggingface_repo_dir(repo_id);
2562    latest_huggingface_repo_snapshot_in(&repo_dir)
2563}
2564
2565fn latest_huggingface_repo_snapshot_in(repo_dir: &Path) -> Option<PathBuf> {
2566    if let Some(snapshot) = resolve_huggingface_ref_snapshot(repo_dir, "main") {
2567        return Some(snapshot);
2568    }
2569
2570    let snapshots = repo_dir.join("snapshots");
2571    let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(snapshots)
2572        .ok()?
2573        .filter_map(Result::ok)
2574        .map(|e| e.path())
2575        .filter(|p| p.is_dir() && snapshot_looks_ready(p))
2576        .map(|path| {
2577            let modified = path
2578                .metadata()
2579                .and_then(|metadata| metadata.modified())
2580                .unwrap_or(SystemTime::UNIX_EPOCH);
2581            (modified, path)
2582        })
2583        .collect();
2584    candidates.sort();
2585    candidates.pop().map(|(_, path)| path)
2586}
2587
2588fn snapshot_looks_ready(path: &Path) -> bool {
2589    if path.join("config.json").exists() || path.join("model_index.json").exists() {
2590        return true;
2591    }
2592    snapshot_contains_ext(path, "safetensors")
2593}
2594
2595fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
2596    let Ok(entries) = std::fs::read_dir(root) else {
2597        return false;
2598    };
2599    entries.filter_map(Result::ok).any(|entry| {
2600        let path = entry.path();
2601        if path.is_dir() {
2602            snapshot_contains_ext(&path, ext)
2603        } else {
2604            let ext_matches = path
2605                .extension()
2606                .and_then(|value| value.to_str())
2607                .map(|value| value.eq_ignore_ascii_case(ext))
2608                .unwrap_or(false);
2609            // A matching extension only counts when the file is actually usable
2610            // — a dangling symlink into a pruned blob or a zero-length partial
2611            // must not make a snapshot look ready.
2612            ext_matches && crate::download::cache_file_usable(&path)
2613        }
2614    })
2615}
2616
2617/// Built-in catalog parsed from `builtin_catalog.json`.
2618///
2619/// Adding, removing, or editing a model is a JSON-only change — Rust
2620/// source stays put. The JSON is embedded at compile time via
2621/// `include_str!`, parsed once into a `LazyLock`, and cloned on each
2622/// call. A malformed JSON file fails the integration test
2623/// `builtin_catalog_json_parses` so the binary never ships unable
2624/// to load its own catalog.
2625const BUILTIN_CATALOG_JSON: &str = include_str!("builtin_catalog.json");
2626
2627static BUILTIN_CATALOG: std::sync::LazyLock<Vec<ModelSchema>> = std::sync::LazyLock::new(|| {
2628    serde_json::from_str(BUILTIN_CATALOG_JSON)
2629        .expect("builtin_catalog.json failed to parse — fix the JSON, not this code")
2630});
2631
2632pub(crate) fn builtin_catalog() -> Vec<ModelSchema> {
2633    let mut catalog = BUILTIN_CATALOG.clone();
2634    catalog.extend(crate::openrouter::builtin_schemas());
2635    catalog
2636}
2637
2638/// Test support for consumers that need real built-in rows without consulting
2639/// the developer machine's shared Hugging Face cache.
2640#[doc(hidden)]
2641pub fn builtin_catalog_with_huggingface_hub_for_testing(
2642    models_dir: &Path,
2643    huggingface_hub_root: &Path,
2644) -> Vec<ModelSchema> {
2645    let mut catalog = builtin_catalog();
2646    for schema in &mut catalog {
2647        schema.weights_ready = physical_weights_ready_with_huggingface_hub(
2648            schema,
2649            models_dir,
2650            Some(huggingface_hub_root),
2651        );
2652    }
2653    catalog
2654}
2655
2656#[cfg(test)]
2657mod tests {
2658    use crate::openrouter::StateRootScope;
2659
2660    /// A sharded install missing one shard is NOT installed.
2661    ///
2662    /// The interrupted-download shape from car#808: shard 2 of 2 landed, shard
2663    /// 1 did not. The old any-weights check said "installed", so `ensure_local`
2664    /// returned early without repairing, `pull_model` reported success having
2665    /// done nothing, and the failure surfaced only at load. Synthetic dir — no
2666    /// real weights needed, which is what makes it runnable in CI.
2667    #[test]
2668    fn a_sharded_model_missing_one_shard_is_not_installed() {
2669        let tmp = tempfile::tempdir().unwrap();
2670        let dir = tmp.path();
2671        std::fs::write(
2672            dir.join("model.safetensors.index.json"),
2673            r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
2674                              "b":"model-00002-of-00002.safetensors"}}"#,
2675        )
2676        .unwrap();
2677        // Only shard 2 present — exactly the reported state.
2678        std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
2679        assert!(
2680            !mlx_dir_has_weights(dir),
2681            "a missing shard must read as not-installed, or pull silently no-ops"
2682        );
2683
2684        // Completing the set flips it.
2685        std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
2686        assert!(
2687            mlx_dir_has_weights(dir),
2688            "a complete shard set must read as installed"
2689        );
2690    }
2691
2692    /// A failed pull must be able to NAME the shard it is missing (car#808).
2693    ///
2694    /// The reported symptom was an inference-time
2695    /// `load model-00001-of-00002.safetensors: Path must point to a local file`
2696    /// — a filename the caller had no way to anticipate, surfacing inside a
2697    /// benchmarking run rather than at pull time. `missing_weight_shards` is
2698    /// what lets the pull itself say which shard is absent.
2699    #[test]
2700    fn missing_shards_are_reported_by_name() {
2701        let tmp = tempfile::tempdir().unwrap();
2702        let dir = tmp.path();
2703        std::fs::write(
2704            dir.join("model.safetensors.index.json"),
2705            r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
2706                              "b":"model-00002-of-00002.safetensors"}}"#,
2707        )
2708        .unwrap();
2709        std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
2710
2711        assert_eq!(
2712            missing_weight_shards(dir),
2713            vec!["model-00001-of-00002.safetensors".to_string()],
2714            "the absent shard must be named, not just counted"
2715        );
2716
2717        std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
2718        assert!(
2719            missing_weight_shards(dir).is_empty(),
2720            "a complete shard set must report nothing missing"
2721        );
2722    }
2723
2724    /// No index (single-file or diffusers layout) means there is no shard list
2725    /// to check against — report nothing missing rather than inventing a
2726    /// failure. `mlx_dir_has_weights` remains the completeness signal there.
2727    #[test]
2728    fn missing_shards_is_empty_without_an_index() {
2729        let tmp = tempfile::tempdir().unwrap();
2730        std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
2731        assert!(missing_weight_shards(tmp.path()).is_empty());
2732
2733        // An unreadable index is a metadata problem, not proof of a missing
2734        // shard — same call `mlx_dir_has_weights` makes, so they cannot disagree.
2735        let bad = tempfile::tempdir().unwrap();
2736        std::fs::write(bad.path().join("model.safetensors.index.json"), b"not json").unwrap();
2737        assert!(missing_weight_shards(bad.path()).is_empty());
2738    }
2739
2740    /// A single-file model has no index; the any-weights walk still governs.
2741    #[test]
2742    fn a_single_file_model_still_counts_without_an_index() {
2743        let tmp = tempfile::tempdir().unwrap();
2744        std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
2745        assert!(mlx_dir_has_weights(tmp.path()));
2746    }
2747
2748    /// A present but unreadable index is authoritative evidence we cannot
2749    /// validate, so readiness fails closed even if a stray weight file exists.
2750    #[test]
2751    fn an_unparseable_index_fails_closed() {
2752        let tmp = tempfile::tempdir().unwrap();
2753        std::fs::write(
2754            tmp.path().join("model.safetensors.index.json"),
2755            b"{not-json",
2756        )
2757        .unwrap();
2758        std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
2759        assert!(
2760            !mlx_dir_has_weights(tmp.path()),
2761            "an unreadable index must not fall back to a stray weight"
2762        );
2763    }
2764
2765    use super::*;
2766    use tempfile::TempDir;
2767
2768    #[test]
2769    fn mlx_dir_has_weights_detects_completeness() {
2770        let tmp = TempDir::new().unwrap();
2771        let dir = tmp.path();
2772
2773        // A config-only stub is NOT complete (car-releases#391).
2774        std::fs::write(dir.join("config.json"), "{}").unwrap();
2775        std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
2776        assert!(
2777            !mlx_dir_has_weights(dir),
2778            "config-only stub must not count as installed"
2779        );
2780
2781        // The bare sharded index without shards is still incomplete.
2782        std::fs::write(dir.join("model.safetensors.index.json"), "{}").unwrap();
2783        assert!(!mlx_dir_has_weights(dir), "index.json alone is not weights");
2784
2785        // A real single-file layout has no shard index.
2786        std::fs::remove_file(dir.join("model.safetensors.index.json")).unwrap();
2787        std::fs::write(dir.join("model.safetensors"), b"\x00\x01\x02").unwrap();
2788        assert!(mlx_dir_has_weights(dir));
2789    }
2790
2791    #[test]
2792    fn mlx_dir_has_weights_handles_sharded_and_dangling_symlinks() {
2793        let sharded = TempDir::new().unwrap();
2794        std::fs::write(sharded.path().join("config.json"), "{}").unwrap();
2795        std::fs::write(
2796            sharded.path().join("model-00001-of-00002.safetensors"),
2797            b"\x00",
2798        )
2799        .unwrap();
2800        assert!(mlx_dir_has_weights(sharded.path()), "sharded shard counts");
2801
2802        // A DANGLING *.safetensors symlink (into a pruned HF cache) must NOT
2803        // count — Path::exists() follows the link and returns false.
2804        #[cfg(unix)]
2805        {
2806            let dangling = TempDir::new().unwrap();
2807            std::fs::write(dangling.path().join("config.json"), "{}").unwrap();
2808            std::os::unix::fs::symlink(
2809                dangling.path().join("does-not-exist"),
2810                dangling.path().join("model.safetensors"),
2811            )
2812            .unwrap();
2813            assert!(
2814                !mlx_dir_has_weights(dangling.path()),
2815                "dangling weight symlink must count as absent"
2816            );
2817        }
2818    }
2819
2820    /// A registry over a temp state root, bundled with the two things that
2821    /// must outlive it: the temp dir, and this crate's environment lock.
2822    ///
2823    /// Holding the lock is not optional, and RETURNING it rather than binding
2824    /// it inside the helper is the whole point — a guard dropped at the end of
2825    /// `test_registry()` protects nothing. Every `UnifiedRegistry::new*` ends
2826    /// in `refresh_availability()`, which clears the process-global gateway
2827    /// observation AND `$CAR_HOME/gateway-state.json` whenever no Parslee
2828    /// token is present — exactly a CI runner's state. Two tests in this
2829    /// module (`a_gateway_that_reports_no_upstream_stops_being_advertised`,
2830    /// `a_rejected_credential_stops_the_managed_lane_being_advertised`) assert
2831    /// on that same observation, so ANY unserialized construction in this
2832    /// module can fail them. #989 locked the two direct
2833    /// `refresh_availability()` call sites but not the constructor path; this
2834    /// closes it. `cargo nextest` hides the family by giving every test its
2835    /// own process, which is why it stayed hidden until `shared-process-test`
2836    /// put a threaded `cargo test` on the per-PR path — see
2837    /// docs/solutions/process-global-state-in-tests.md.
2838    ///
2839    /// Modelled on `adaptive_router::tests::TestRegistry`, which already has
2840    /// this shape for the same reason.
2841    struct TestRegistry {
2842        registry: UnifiedRegistry,
2843        // Field drop order is intentional: tear the temp state root down
2844        // before releasing the lock, so the next test never sees a half-gone
2845        // `CAR_HOME`-shaped directory.
2846        _tmp: TempDir,
2847        _environment: tokio::sync::MutexGuard<'static, ()>,
2848    }
2849
2850    impl std::ops::Deref for TestRegistry {
2851        type Target = UnifiedRegistry;
2852
2853        fn deref(&self) -> &Self::Target {
2854            &self.registry
2855        }
2856    }
2857
2858    impl std::ops::DerefMut for TestRegistry {
2859        fn deref_mut(&mut self) -> &mut Self::Target {
2860            &mut self.registry
2861        }
2862    }
2863
2864    fn test_registry() -> TestRegistry {
2865        let _environment = crate::openrouter::test_environment_scope();
2866        let tmp = TempDir::new().unwrap();
2867        let registry = UnifiedRegistry::new_with_state_root(
2868            tmp.path().to_path_buf(),
2869            tmp.path().join("models"),
2870        );
2871        TestRegistry {
2872            registry,
2873            _tmp: tmp,
2874            _environment,
2875        }
2876    }
2877
2878    fn test_generate_schema(id: &str, name: &str, source: ModelSource) -> ModelSchema {
2879        ModelSchema {
2880            id: id.into(),
2881            name: name.into(),
2882            provider: "local".into(),
2883            family: "qwen3".into(),
2884            version: "test".into(),
2885            capabilities: vec![ModelCapability::Generate],
2886            context_length: 4096,
2887            max_output_tokens: None,
2888            param_count: String::new(),
2889            quantization: None,
2890            performance: PerformanceEnvelope::default(),
2891            cost: CostModel::default(),
2892            source,
2893            tags: vec![],
2894            supported_params: vec![],
2895            public_benchmarks: vec![],
2896            trust_tier: crate::schema::TrustTier::Curated,
2897            deprecated: false,
2898            available: false,
2899            weights_ready: false,
2900        }
2901    }
2902
2903    /// Parslee-ai/car#894: the schema knows whether weights are on disk, but
2904    /// the schema → `ModelInfo` projection dropped that fact, so the CLI could
2905    /// only render `available` and contradicted `car doctor`. The projection
2906    /// must carry `weights_ready` through in both directions.
2907    #[test]
2908    fn model_info_carries_weights_ready_through_the_projection() {
2909        let mut schema = test_generate_schema(
2910            "mlx-community/car894-test-4bit",
2911            "car894-test-4bit",
2912            ModelSource::Mlx {
2913                hf_repo: "mlx-community/car894-test-4bit".into(),
2914                hf_weight_file: None,
2915            },
2916        );
2917
2918        schema.weights_ready = false;
2919        assert!(
2920            !ModelInfo::from(&schema).weights_ready,
2921            "a schema with no weights on disk must project weights_ready = false"
2922        );
2923
2924        schema.weights_ready = true;
2925        assert!(
2926            ModelInfo::from(&schema).weights_ready,
2927            "a schema with weights on disk must project weights_ready = true"
2928        );
2929    }
2930
2931    /// The `weights_ready` flag is only meaningful for entries CAR actually
2932    /// downloads. `downloads_weights` is that gate, and it has to survive the
2933    /// schema → `ModelInfo` projection or the CLI is back to guessing from
2934    /// `is_local` — which is what made `windows/speech-synthesis:os` claim an
2935    /// install it does not have (#894).
2936    #[test]
2937    fn model_info_carries_downloads_weights_through_the_projection() {
2938        let mlx = test_generate_schema(
2939            "mlx-community/car894-test-4bit",
2940            "car894-test-4bit",
2941            ModelSource::Mlx {
2942                hf_repo: "mlx-community/car894-test-4bit".into(),
2943                hf_weight_file: None,
2944            },
2945        );
2946        assert!(
2947            ModelInfo::from(&mlx).downloads_weights,
2948            "an MLX entry downloads weights"
2949        );
2950
2951        // OS-owned sources are local even though CAR never downloads for
2952        // them, so a projection that reused `is_local` would get them wrong.
2953        for (label, source) in [
2954            ("windows speech", ModelSource::WindowsSpeech {}),
2955            (
2956                "apple foundation",
2957                ModelSource::AppleFoundationModels { use_case: None },
2958            ),
2959        ] {
2960            let schema = test_generate_schema("car894/os-model", "os-model", source);
2961            let info = ModelInfo::from(&schema);
2962            assert!(
2963                !info.downloads_weights,
2964                "{label} installs nothing, so the projection must say so"
2965            );
2966            assert!(
2967                info.is_local,
2968                "{label} is still local — which is exactly why is_local cannot stand in"
2969            );
2970        }
2971
2972        // Raw vLLM-MLX is independently owned and therefore remote even when
2973        // its endpoint happens to use a loopback spelling. CAR neither
2974        // downloads its weights nor imposes the client machine's hardware
2975        // requirements on it.
2976        let external = test_generate_schema(
2977            "car894/external-model",
2978            "external-model",
2979            ModelSource::VllmMlx {
2980                endpoint: "http://localhost:8000".into(),
2981                model_name: "mlx-community/car894-test-4bit".into(),
2982            },
2983        );
2984        assert!(!external.is_local());
2985        assert!(external.is_remote());
2986        assert!(!external.requires_apple_silicon());
2987        let info = ModelInfo::from(&external);
2988        assert!(!info.is_local);
2989        assert!(
2990            !info.downloads_weights,
2991            "external vllm-mlx owns its weights, so CAR installs nothing"
2992        );
2993    }
2994
2995    #[test]
2996    fn model_info_classifies_only_raw_vllm_mlx_as_operator_managed_external() {
2997        for endpoint in ["http://localhost:8000", "https://models.example.invalid/v1"] {
2998            let schema = test_generate_schema(
2999                "external/model",
3000                "external-model",
3001                ModelSource::VllmMlx {
3002                    endpoint: endpoint.into(),
3003                    model_name: "mlx-community/external-model".into(),
3004                },
3005            );
3006            let info = ModelInfo::from(&schema);
3007            assert!(info.operator_managed_external_runtime);
3008            assert_eq!(
3009                serde_json::to_value(info).unwrap()["operator_managed_external_runtime"],
3010                true
3011            );
3012        }
3013
3014        for source in [
3015            ModelSource::RemoteApi {
3016                endpoint: "https://cloud.example.invalid/v1".into(),
3017                api_key_env: "CAR_TEST_KEY".into(),
3018                api_key_envs: vec![],
3019                api_version: None,
3020                protocol: crate::schema::ApiProtocol::OpenAiCompat,
3021            },
3022            ModelSource::ManagedVllmMlx {
3023                hf_repo: "mlx-community/car-owned-model".into(),
3024                hf_weight_file: None,
3025            },
3026        ] {
3027            assert!(
3028                !ModelInfo::from(&test_generate_schema(
3029                    "not-external/model",
3030                    "not-external-model",
3031                    source,
3032                ))
3033                .operator_managed_external_runtime
3034            );
3035        }
3036    }
3037
3038    /// The fresh-machine state from Parslee-ai/car#894, end to end: an MLX
3039    /// entry with a declared `hf_repo` and an EMPTY models dir. It is
3040    /// `available` (ensure_local lazy-downloads on first use, #164) and
3041    /// simultaneously NOT `weights_ready` (nothing has been fetched). Those
3042    /// two facts are what `car models list` and `car doctor` were each
3043    /// reporting in isolation, which is why they disagreed.
3044    #[test]
3045    fn fresh_machine_mlx_entry_is_available_but_not_weights_ready() {
3046        let mut reg = test_registry();
3047        let id = "mlx-community/car894-fresh-4bit";
3048        reg.register(test_generate_schema(
3049            id,
3050            "car894-fresh-4bit",
3051            ModelSource::Mlx {
3052                hf_repo: "mlx-community/car894-fresh-4bit".into(),
3053                hf_weight_file: None,
3054            },
3055        ));
3056
3057        let registered = reg
3058            .get(id)
3059            .expect("the model just registered must be in the registry");
3060        let info = ModelInfo::from(registered);
3061
3062        // The models dir is a fresh TempDir, so nothing is downloaded.
3063        assert!(
3064            !registered.weights_ready,
3065            "an empty models dir means no weights on disk"
3066        );
3067        assert!(
3068            !info.weights_ready,
3069            "the CLI-facing projection must report the same: nothing installed"
3070        );
3071
3072        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3073        {
3074            // On a real MLX target this is the exact contradiction #894
3075            // reported: runnable here, but not installed.
3076            assert!(
3077                registered.available,
3078                "a declared hf_repo makes an MLX entry runnable before download (#164)"
3079            );
3080            assert!(
3081                info.available,
3082                "the projection must keep reporting it as runnable"
3083            );
3084        }
3085        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3086        {
3087            // Off an MLX target the backend isn't compiled in, so it is
3088            // neither runnable nor installed — still not a contradiction.
3089            assert!(
3090                !registered.available,
3091                "MLX cannot execute on this target, so it must not be runnable"
3092            );
3093            assert!(!info.available);
3094        }
3095    }
3096
3097    fn write_complete_mlx_snapshot(dir: &Path) {
3098        std::fs::create_dir_all(dir).unwrap();
3099        std::fs::write(dir.join("config.json"), b"{}").unwrap();
3100        std::fs::write(dir.join("tokenizer.json"), b"{}").unwrap();
3101        std::fs::write(
3102            dir.join("model.safetensors.index.json"),
3103            r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
3104        )
3105        .unwrap();
3106        std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"one").unwrap();
3107        std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
3108    }
3109
3110    #[test]
3111    fn complete_managed_and_shared_mlx_snapshots_are_physically_ready() {
3112        let schema = test_generate_schema(
3113            "mlx/qwen3-4b:4bit",
3114            "Qwen3-4B-MLX",
3115            ModelSource::Mlx {
3116                hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
3117                hf_weight_file: None,
3118            },
3119        );
3120        let root = tempfile::tempdir().unwrap();
3121        let managed = root.path().join("managed");
3122        let shared = root.path().join("shared");
3123
3124        write_complete_mlx_snapshot(&managed);
3125        assert!(mlx_weights_ready_at(&schema, &managed, None));
3126
3127        std::fs::remove_dir_all(&managed).unwrap();
3128        write_complete_mlx_snapshot(&shared);
3129        assert!(mlx_weights_ready_at(&schema, &managed, Some(&shared)));
3130    }
3131
3132    #[test]
3133    fn zero_byte_gguf_is_not_physically_ready() {
3134        let schema = test_generate_schema(
3135            "qwen/qwen3-4b:q4_k_m",
3136            "Qwen3-4B",
3137            ModelSource::Local {
3138                hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
3139                hf_filename: "model.gguf".into(),
3140                tokenizer_repo: "Qwen/Qwen3-4B".into(),
3141            },
3142        );
3143        let root = tempfile::tempdir().unwrap();
3144        let model_dir = root.path().join(&schema.name);
3145        std::fs::create_dir_all(&model_dir).unwrap();
3146        std::fs::write(model_dir.join("model.gguf"), b"").unwrap();
3147
3148        assert!(!physical_weights_ready(&schema, root.path()));
3149        std::fs::write(model_dir.join("model.gguf"), b"gguf").unwrap();
3150        assert!(physical_weights_ready(&schema, root.path()));
3151    }
3152
3153    #[test]
3154    fn shared_mlx_snapshot_missing_an_indexed_shard_is_not_physically_ready() {
3155        let schema = test_generate_schema(
3156            "mlx/qwen3-8b:4bit",
3157            "Qwen3-8B-MLX",
3158            ModelSource::Mlx {
3159                hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
3160                hf_weight_file: None,
3161            },
3162        );
3163        let root = tempfile::tempdir().unwrap();
3164        let managed = root.path().join("managed");
3165        let shared = root.path().join("shared");
3166        write_complete_mlx_snapshot(&shared);
3167        std::fs::remove_file(shared.join("model-00001-of-00002.safetensors")).unwrap();
3168
3169        assert!(!mlx_weights_ready_at(&schema, &managed, Some(&shared)));
3170    }
3171
3172    /// `refresh_availability` must probe each DISTINCT credential once, not
3173    /// once per model (car-releases#75).
3174    ///
3175    /// A credential probe is a keychain query on macOS — ~14 ms. The stock
3176    /// catalog is ~72 models over roughly a dozen providers, so probing
3177    /// per-model cost ~1 s per refresh; a snapshot runs per request, and
3178    /// `estimated_tokens` ran a second one, so a delegated call waited ~2 s
3179    /// before it ever reached the host's runner.
3180    ///
3181    /// Asserted by COUNTING probes rather than by timing: a latency assertion
3182    /// would be flaky on a loaded CI runner, and the invariant that actually
3183    /// matters is "O(providers), not O(models)".
3184    /// Parslee-ai/car#786 — the catalog must stop advertising a managed
3185    /// namespace once the gateway has said it has no upstream for it.
3186    ///
3187    /// This is the reported symptom directly: `car models list` showed all ten
3188    /// `parslee/openrouter/*` aliases as `avail=yes` while every one of them
3189    /// 503'd, because availability for those rows resolves to "is a Parslee
3190    /// session signed in" and nothing more. A harness picked one on the
3191    /// strength of that claim and lost a full benchmark sweep to it.
3192    ///
3193    /// Asserted through the real `curated_schemas()` rows rather than a
3194    /// fixture, because the bug lives in how THOSE rows derive availability.
3195    ///
3196    /// Isolates `CAR_HOME` for the same reason its credential sibling does.
3197    /// `note_gateway_unconfigured` writes `gateway-state.json` under the state
3198    /// root, and cleaning up afterwards is not enough: nextest runs every test
3199    /// in its own process, so while this one holds the flag a DIFFERENT process
3200    /// can load it and fail on an availability assertion it never made
3201    /// (Parslee-ai/car#986). The crate's serial lock cannot serialize across
3202    /// processes; a private state root can.
3203    #[test]
3204    fn a_gateway_that_reports_no_upstream_stops_being_advertised() {
3205        let _guard = crate::openrouter::test_environment_scope();
3206        // See the note above: `StateRootScope` is the RAII form, so a panic in
3207        // an assertion below cannot leave `CAR_HOME` pointing at a deleted dir.
3208        let _home = StateRootScope::new();
3209        crate::openrouter::clear_gateway_unconfigured();
3210
3211        let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
3212            .into_iter()
3213            .filter(|s| crate::openrouter::is_curated_managed_gateway_alias(&s.id))
3214            .collect();
3215        assert!(
3216            !managed.is_empty(),
3217            "precondition: the curated catalog must still carry managed aliases"
3218        );
3219
3220        let availability_of = |schema: &ModelSchema| match &schema.source {
3221            ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
3222                &schema.id,
3223                &schema.provider,
3224                provider,
3225                auth,
3226                // Signed in — the state in which the bug reported `yes`.
3227                true,
3228                &std::collections::HashMap::new(),
3229            ),
3230            other => panic!("managed aliases must be Proprietary, got {other:?}"),
3231        };
3232
3233        assert!(
3234            managed.iter().all(availability_of),
3235            "precondition: an authenticated session advertises these today"
3236        );
3237
3238        crate::openrouter::note_gateway_unconfigured();
3239        assert!(
3240            managed.iter().all(|s| !availability_of(s)),
3241            "after the gateway says it has no OpenRouter upstream, every alias in \
3242             the namespace must report unavailable — that claim is what cost the \
3243             benchmark sweep in #786"
3244        );
3245
3246        // Self-correcting: forgetting the observation restores the optimistic
3247        // claim, so an environment that later gains OpenRouter is not
3248        // permanently written off.
3249        crate::openrouter::clear_gateway_unconfigured();
3250        assert!(
3251            managed.iter().all(availability_of),
3252            "the suppression must be recoverable, not a one-way latch"
3253        );
3254    }
3255
3256    /// Parslee-ai/car#986 — building a registry must not clear the gateway
3257    /// observation as a side effect of construction.
3258    ///
3259    /// `new_with_catalog_public_key` ends in a `refresh_availability`, and that
3260    /// refresh forgets the session-scoped evidence whenever no Parslee session
3261    /// is present. #989 took the crate's serial lock in the two tests that call
3262    /// `refresh_availability` directly, but roughly twenty others reach the same
3263    /// clear just by constructing a registry, and none of them took the lock —
3264    /// so the class of bug survived the fix that was supposed to close it.
3265    ///
3266    /// This first case pins the harmless direction: a live session, so nothing
3267    /// is forgotten. `CAR_HOME` is isolated so the durable half of the
3268    /// observation lands in a temp dir rather than the developer's `~/.car`.
3269    #[test]
3270    fn constructing_a_registry_with_a_live_session_leaves_the_gateway_observation_alone() {
3271        let _guard = crate::openrouter::test_environment_scope();
3272        let home = StateRootScope::new();
3273
3274        crate::openrouter::note_gateway_unconfigured();
3275        let _registry = UnifiedRegistry::new_with_session(
3276            home.path().to_path_buf(),
3277            home.path().join("models"),
3278            None,
3279            SessionProbe::Fixed(true),
3280        );
3281
3282        let observed = crate::openrouter::gateway_unconfigured();
3283        let persisted = crate::openrouter::gateway_state_path().exists();
3284
3285        crate::openrouter::clear_gateway_unconfigured();
3286
3287        assert!(
3288            observed,
3289            "a signed-in session has no reason to forget what the gateway said"
3290        );
3291        assert!(
3292            persisted,
3293            "the durable half of the observation must survive construction too"
3294        );
3295    }
3296
3297    /// The other half of Parslee-ai/car#986: the production guarantee from
3298    /// Parslee-ai/car#786 is unchanged. When the session really is gone, the
3299    /// evidence learned about it is discarded — in memory and on disk — because
3300    /// the next sign-in may be to an org that has OpenRouter configured.
3301    ///
3302    /// Pinned rather than bypassed: the fix narrows *who* may forget, not
3303    /// whether forgetting still happens.
3304    #[test]
3305    fn constructing_a_registry_with_no_session_still_forgets_the_gateway_observation() {
3306        let _guard = crate::openrouter::test_environment_scope();
3307        let home = StateRootScope::new();
3308
3309        crate::openrouter::note_gateway_unconfigured();
3310        let recorded = crate::openrouter::gateway_unconfigured();
3311        let _registry = UnifiedRegistry::new_with_session(
3312            home.path().to_path_buf(),
3313            home.path().join("models"),
3314            None,
3315            SessionProbe::Fixed(false),
3316        );
3317
3318        let observed = crate::openrouter::gateway_unconfigured();
3319        let persisted = crate::openrouter::gateway_state_path().exists();
3320
3321        crate::openrouter::clear_gateway_unconfigured();
3322
3323        assert!(
3324            recorded,
3325            "precondition: the observation is on record before construction"
3326        );
3327        assert!(
3328            !observed,
3329            "sign-out must still discard the session-scoped verdict (#786)"
3330        );
3331        assert!(
3332            !persisted,
3333            "and the durable copy with it — otherwise the next sign-in inherits it from disk"
3334        );
3335    }
3336
3337    /// The case that actually failed before Parslee-ai/car#986 was fixed: an
3338    /// ordinary test registry, built the way every other test in this crate
3339    /// builds one, with no serial lock and no opinion about auth.
3340    ///
3341    /// On a machine with no Parslee session — a CI runner — construction alone
3342    /// used to wipe the observation, which is how
3343    /// `a_gateway_that_reports_no_upstream_stops_being_advertised` lost its flag
3344    /// between the `note_` call and the assertion under `cargo test`.
3345    ///
3346    /// Two assertions, deliberately. The observation one reproduces the CI
3347    /// condition and is the regression proper — but it only *fails* on main
3348    /// when the runner is signed out, so on a developer Mac with a live
3349    /// keychain session it would pass either way. The probe one holds
3350    /// regardless of the machine: the default a test registry is built with
3351    /// must be one that cannot forget session evidence, which is the whole
3352    /// content of the fix.
3353    #[test]
3354    fn an_ordinary_test_registry_does_not_disturb_a_separately_set_observation() {
3355        let _guard = crate::openrouter::test_environment_scope();
3356        let home = StateRootScope::new();
3357
3358        crate::openrouter::note_gateway_unconfigured();
3359        let _registry = UnifiedRegistry::new_with_state_root(
3360            home.path().to_path_buf(),
3361            home.path().join("models"),
3362        );
3363
3364        let observed = crate::openrouter::gateway_unconfigured();
3365
3366        crate::openrouter::clear_gateway_unconfigured();
3367
3368        assert!(
3369            observed,
3370            "constructing a registry is not a statement about the session, so it \
3371             must not erase an observation another test just recorded (#986)"
3372        );
3373        assert!(
3374            !SessionProbe::Inert.may_forget_session_evidence(),
3375            "the `cfg(test)` construction default must be a probe that answers \
3376             the session question without acting on it — this is the half of \
3377             the guarantee that does not depend on whether the runner happens \
3378             to be signed in"
3379        );
3380    }
3381
3382    /// Parslee-ai/car#887 — a signed-in session is not a WORKING one, and the
3383    /// catalog must stop advertising `parslee/*` once the server has rejected
3384    /// the credential.
3385    ///
3386    /// The reported symptom: on a machine with a stale sign-in, every routing
3387    /// pass offered the managed aliases as top-quality candidates and every
3388    /// request 401'd, so the router walked the fallback chain silently on every
3389    /// call. `access_token_is_available()` is existence-only, so `true` is
3390    /// exactly the state the bug reported as available.
3391    ///
3392    /// Isolates `CAR_HOME` rather than writing the observation into the
3393    /// developer's real `~/.car`; the sibling gateway test now does the same.
3394    #[test]
3395    fn a_rejected_credential_stops_the_managed_lane_being_advertised() {
3396        let _guard = crate::openrouter::test_environment_scope();
3397        let _home = StateRootScope::new();
3398        crate::parslee_credential::clear_credential_rejected();
3399
3400        let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
3401            .into_iter()
3402            .filter(|s| s.provider == "parslee")
3403            .collect();
3404        assert!(
3405            !managed.is_empty(),
3406            "precondition: the curated catalog must still carry parslee rows"
3407        );
3408
3409        let availability_of = |schema: &ModelSchema| match &schema.source {
3410            ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
3411                &schema.id,
3412                &schema.provider,
3413                provider,
3414                auth,
3415                // Signed in — the state in which the bug reported `yes`.
3416                true,
3417                &std::collections::HashMap::new(),
3418            ),
3419            other => panic!("parslee rows must be Proprietary, got {other:?}"),
3420        };
3421
3422        assert!(
3423            managed.iter().all(availability_of),
3424            "precondition: an authenticated session advertises these today"
3425        );
3426
3427        crate::parslee_credential::note_credential_rejected();
3428        assert!(
3429            managed.iter().all(|s| !availability_of(s)),
3430            "after the server rejects the credential, EVERY parslee row must \
3431             report unavailable — unlike the gateway verdict this is not scoped \
3432             to the curated OpenRouter aliases, because a dead credential kills \
3433             the whole namespace"
3434        );
3435
3436        // Recoverable, not a one-way latch: the operator signs in again, the
3437        // next org lookup succeeds and clears the verdict.
3438        crate::parslee_credential::clear_credential_rejected();
3439        assert!(
3440            managed.iter().all(availability_of),
3441            "the suppression must lift once the credential works again"
3442        );
3443    }
3444
3445    #[test]
3446    fn refresh_availability_probes_each_credential_once_not_per_model() {
3447        // Serialized with the other tests that touch the gateway observation.
3448        // `refresh_availability` CLEARS it when no Parslee token is present
3449        // (see the `!parslee_oauth_available` branch above), which is exactly
3450        // the state a CI runner is in — so without this lock, running under
3451        // `cargo test` (one process per crate target, tests threaded in
3452        // parallel inside it) this test can wipe the flag that
3453        // `a_gateway_that_reports_no_upstream_stops_being_advertised` set,
3454        // between its `note_` call and its assertion. `cargo nextest` hides it
3455        // by giving every test its own process, and every per-PR job runs
3456        // nextest — which is why it surfaced in `check-windows` instead
3457        // (Parslee-ai/car#986). See docs/solutions/process-global-state-in-tests.md
3458        // for the family and the full list of shared-process CI runs.
3459        let _environment = crate::openrouter::test_environment_scope();
3460        let tmp = TempDir::new().unwrap();
3461        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
3462        for i in 0..25 {
3463            let mut schema = test_generate_schema(
3464                &format!("openrouter/model-{i}"),
3465                &format!("model-{i}"),
3466                ModelSource::RemoteApi {
3467                    protocol: crate::schema::ApiProtocol::OpenRouter,
3468                    endpoint: "https://openrouter.ai/api/v1".into(),
3469                    api_key_env: "OPENROUTER_API_KEY".into(),
3470                    api_key_envs: vec![],
3471                    api_version: None,
3472                },
3473            );
3474            schema.provider = "openrouter".into();
3475            registry.register(schema);
3476        }
3477
3478        crate::openrouter::reset_credential_source_call_count();
3479        registry.refresh_availability();
3480        let calls = crate::openrouter::credential_source_call_count();
3481
3482        assert_eq!(
3483            calls, 1,
3484            "refresh_availability probed the OpenRouter credential {calls} times for 25 models; \
3485             it must resolve each distinct credential once per refresh, not once per model"
3486        );
3487    }
3488
3489    /// `pull` on a `vllm-mlx` model must report the HuggingFace cache directory
3490    /// the external runtime will actually open — CAR's own
3491    /// `~/.car/models/<name>` layout is the wrong shape for a model a Python
3492    /// runtime resolves by repo id, and reporting the wrong path would make
3493    /// "where did my 16 GB go" unanswerable.
3494    #[test]
3495    fn a_vllm_mlx_pull_targets_the_shared_huggingface_cache() {
3496        let repo = "mlx-community/Qwen3.8-27B-4bit";
3497        let dir = huggingface_repo_dir(repo);
3498        assert!(
3499            dir.ends_with("models--mlx-community--Qwen3.8-27B-4bit"),
3500            "got {}",
3501            dir.display()
3502        );
3503        assert!(
3504            dir.parent().is_some_and(|p| p.ends_with("hub")),
3505            "must live under the HF cache's hub/ root, got {}",
3506            dir.display()
3507        );
3508    }
3509
3510    /// A raw `VllmMlx` row is operator-managed, even when its endpoint is a
3511    /// loopback URL. Only `ManagedVllmMlx` opts into CAR's provisioned runtime.
3512    #[test]
3513    fn external_vllm_mlx_does_not_become_managed_from_a_loopback_endpoint() {
3514        // Serialized with the other tests that touch the gateway observation.
3515        // `refresh_availability` CLEARS it when no Parslee token is present
3516        // (see the `!parslee_oauth_available` branch above), which is exactly
3517        // the state a CI runner is in — so without this lock, running under
3518        // `cargo test` (one process per crate target, tests threaded in
3519        // parallel inside it) this test can wipe the flag that
3520        // `a_gateway_that_reports_no_upstream_stops_being_advertised` set,
3521        // between its `note_` call and its assertion. `cargo nextest` hides it
3522        // by giving every test its own process, and every per-PR job runs
3523        // nextest — which is why it surfaced in `check-windows` instead
3524        // (Parslee-ai/car#986). See docs/solutions/process-global-state-in-tests.md
3525        // for the family and the full list of shared-process CI runs.
3526        let _environment = crate::openrouter::test_environment_scope();
3527        let tmp = TempDir::new().unwrap();
3528        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
3529        let schema = test_generate_schema(
3530            "vllm-mlx/arch-the-rust-backend-cannot-load",
3531            "external-only-model",
3532            ModelSource::VllmMlx {
3533                endpoint: "http://localhost:8000".into(),
3534                model_name: "mlx-community/Qwen3.8-27B-4bit".into(),
3535            },
3536        );
3537        registry.register(schema);
3538
3539        // Deliberately NOT set: no external endpoint has been declared healthy.
3540        assert!(
3541            std::env::var("VLLM_MLX_ENDPOINT").is_err(),
3542            "test precondition: VLLM_MLX_ENDPOINT must be unset"
3543        );
3544        registry.refresh_availability();
3545
3546        let model = registry
3547            .get("vllm-mlx/arch-the-rust-backend-cannot-load")
3548            .expect("registered model should be present");
3549        assert!(
3550            !model.available,
3551            "an external vllm-mlx row remains external even on loopback; only an \
3552             explicit ManagedVllmMlx source may use CAR's runtime"
3553        );
3554    }
3555
3556    #[test]
3557    fn user_config_load_and_save_force_community_trust() {
3558        let tmp = TempDir::new().unwrap();
3559        let models_dir = tmp.path().join("models");
3560        let config_path = tmp.path().join("models.json");
3561        let schema = test_generate_schema(
3562            "user/test-model",
3563            "user-test-model",
3564            ModelSource::RemoteApi {
3565                endpoint: "https://attacker.invalid/v1/chat/completions".into(),
3566                api_key_env: "CAR_USER_MODEL_TEST_KEY".into(),
3567                api_key_envs: vec![],
3568                api_version: None,
3569                protocol: crate::schema::ApiProtocol::OpenAiCompat,
3570            },
3571        );
3572        let mut omitted_tier = serde_json::to_value(schema.clone()).unwrap();
3573        omitted_tier.as_object_mut().unwrap().remove("trust_tier");
3574        std::fs::write(
3575            &config_path,
3576            serde_json::to_vec_pretty(&vec![omitted_tier]).unwrap(),
3577        )
3578        .unwrap();
3579
3580        let mut loaded = UnifiedRegistry::new_empty(models_dir.clone());
3581        loaded.load_user_config().unwrap();
3582        assert_eq!(
3583            loaded.get("user/test-model").unwrap().trust_tier,
3584            crate::schema::TrustTier::Community
3585        );
3586
3587        let mut persisted = UnifiedRegistry::new_empty(models_dir);
3588        persisted.register_user_model(schema);
3589        persisted.save_user_config().unwrap();
3590        let saved: Vec<ModelSchema> =
3591            serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
3592        assert_eq!(saved.len(), 1);
3593        assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
3594    }
3595
3596    #[test]
3597    fn persisted_user_model_cannot_shadow_managed_openrouter_alias() {
3598        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
3599        // which WRITES the process-global gateway observation. Full reasoning
3600        // on `TestRegistry` in this crate; family in
3601        // docs/solutions/process-global-state-in-tests.md.
3602        let _environment = crate::openrouter::test_environment_scope();
3603        let tmp = TempDir::new().unwrap();
3604        let models_dir = tmp.path().join("models");
3605        let config_path = tmp.path().join("models.json");
3606        let mut shadow = crate::openrouter::curated_schemas()
3607            .into_iter()
3608            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
3609            .unwrap();
3610        shadow.provider = "attacker".into();
3611        std::fs::write(
3612            &config_path,
3613            serde_json::to_vec_pretty(&vec![shadow]).unwrap(),
3614        )
3615        .unwrap();
3616
3617        let registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
3618        let actual = registry
3619            .get("parslee/openrouter/frontier-general")
3620            .expect("compiled managed alias must remain present");
3621        assert_eq!(actual.provider, "parslee");
3622        assert_eq!(
3623            crate::openrouter::canonical_managed_gateway_selector(actual),
3624            Some("parslee/openrouter/frontier-general")
3625        );
3626    }
3627
3628    #[test]
3629    fn user_config_persistence_excludes_signed_rows_and_keeps_builtin_tagged_user_rows() {
3630        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
3631        // which WRITES the process-global gateway observation. Full reasoning
3632        // on `TestRegistry` in this crate; family in
3633        // docs/solutions/process-global-state-in-tests.md.
3634        let _environment = crate::openrouter::test_environment_scope();
3635        let tmp = TempDir::new().unwrap();
3636        let models_dir = tmp.path().join("models");
3637        let config_path = tmp.path().join("models.json");
3638
3639        let signed = test_generate_schema(
3640            "signed/catalog-only",
3641            "signed-catalog-only",
3642            ModelSource::RemoteApi {
3643                endpoint: "https://catalog.example/v1".into(),
3644                api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
3645                api_key_envs: vec![],
3646                api_version: None,
3647                protocol: crate::schema::ApiProtocol::OpenAiCompat,
3648            },
3649        );
3650        assert!(!signed.tags.iter().any(|tag| tag == "builtin"));
3651        let (verified, public_key) = crate::catalog::signed_test_catalog(
3652            crate::catalog::CatalogDoc {
3653                version: 81,
3654                models: vec![signed],
3655            },
3656            81,
3657        );
3658        crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
3659
3660        let mut registry = UnifiedRegistry::new_with_catalog_public_key(
3661            tmp.path().to_path_buf(),
3662            models_dir.clone(),
3663            Some(public_key.as_str()),
3664        );
3665        let mut user = test_generate_schema(
3666            "user/builtin-tagged",
3667            "user-builtin-tagged",
3668            ModelSource::RemoteApi {
3669                endpoint: "https://user.example/v1".into(),
3670                api_key_env: "USER_MODEL_TEST_KEY".into(),
3671                api_key_envs: vec![],
3672                api_version: None,
3673                protocol: crate::schema::ApiProtocol::OpenAiCompat,
3674            },
3675        );
3676        user.tags.push("builtin".into());
3677        registry.register_user_model(user);
3678        registry.save_user_config().unwrap();
3679
3680        let saved: Vec<ModelSchema> =
3681            serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
3682        assert_eq!(
3683            saved
3684                .iter()
3685                .map(|model| model.id.as_str())
3686                .collect::<Vec<_>>(),
3687            vec!["user/builtin-tagged"],
3688            "models.json must contain only explicitly user-registered rows"
3689        );
3690        assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
3691
3692        let mut restarted = UnifiedRegistry::new_with_catalog_public_key(
3693            tmp.path().to_path_buf(),
3694            models_dir,
3695            Some(public_key.as_str()),
3696        );
3697        assert_eq!(
3698            restarted.get("signed/catalog-only").unwrap().trust_tier,
3699            crate::schema::TrustTier::Curated,
3700            "user persistence must not demote an unrelated signed catalog row"
3701        );
3702        assert_eq!(
3703            restarted.get("user/builtin-tagged").unwrap().trust_tier,
3704            crate::schema::TrustTier::Community
3705        );
3706        let mut signed_shadow = restarted.get("signed/catalog-only").unwrap().clone();
3707        signed_shadow.name = "user-shadow-of-signed-row".into();
3708        restarted.register_user_model(signed_shadow);
3709        assert_eq!(
3710            restarted.get("signed/catalog-only").unwrap().name,
3711            "signed-catalog-only",
3712            "a user row must not shadow a signature-verified project exact id"
3713        );
3714    }
3715
3716    #[test]
3717    fn empty_user_config_save_clears_stale_rows() {
3718        let tmp = TempDir::new().unwrap();
3719        let models_dir = tmp.path().join("models");
3720        let config_path = tmp.path().join("models.json");
3721        let stale = test_generate_schema(
3722            "user/stale",
3723            "stale",
3724            ModelSource::RemoteApi {
3725                endpoint: "https://stale.example/v1".into(),
3726                api_key_env: "STALE_USER_MODEL_TEST_KEY".into(),
3727                api_key_envs: vec![],
3728                api_version: None,
3729                protocol: crate::schema::ApiProtocol::OpenAiCompat,
3730            },
3731        );
3732        std::fs::write(
3733            &config_path,
3734            serde_json::to_vec_pretty(&vec![stale]).unwrap(),
3735        )
3736        .unwrap();
3737
3738        UnifiedRegistry::new_empty(models_dir)
3739            .save_user_config()
3740            .unwrap();
3741
3742        let saved: Vec<ModelSchema> =
3743            serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
3744        assert!(
3745            saved.is_empty(),
3746            "saving an empty user set must overwrite stale models.json rows"
3747        );
3748    }
3749
3750    #[test]
3751    fn unregister_then_save_removes_the_user_row_from_disk() {
3752        let tmp = TempDir::new().unwrap();
3753        let models_dir = tmp.path().join("models");
3754        let config_path = tmp.path().join("models.json");
3755        let mut registry = UnifiedRegistry::new_empty(models_dir);
3756        registry.register_project_model(test_generate_schema(
3757            "signed/not-user-removable",
3758            "not-user-removable",
3759            ModelSource::RemoteApi {
3760                endpoint: "https://catalog.example/v1".into(),
3761                api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
3762                api_key_envs: vec![],
3763                api_version: None,
3764                protocol: crate::schema::ApiProtocol::OpenAiCompat,
3765            },
3766        ));
3767        assert!(
3768            registry
3769                .unregister_user_model("signed/not-user-removable")
3770                .is_none(),
3771            "the user boundary cannot unregister an untracked catalog row"
3772        );
3773        assert!(registry.get("signed/not-user-removable").is_some());
3774        registry.register_user_model(test_generate_schema(
3775            "user/removable",
3776            "removable",
3777            ModelSource::RemoteApi {
3778                endpoint: "https://user.example/v1".into(),
3779                api_key_env: "REMOVABLE_USER_MODEL_TEST_KEY".into(),
3780                api_key_envs: vec![],
3781                api_version: None,
3782                protocol: crate::schema::ApiProtocol::OpenAiCompat,
3783            },
3784        ));
3785        registry.save_user_config().unwrap();
3786        assert!(registry.unregister_user_model("user/removable").is_some());
3787        registry.save_user_config().unwrap();
3788
3789        let saved: Vec<ModelSchema> =
3790            serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
3791        assert!(saved.is_empty());
3792    }
3793
3794    /// The daemon's `models.register` handler writes through
3795    /// [`user_config_path`]; the registry reads through the state root it was
3796    /// built with. Those two resolvers must land on one file.
3797    ///
3798    /// They did not. The write side moved to `CAR_HOME` while the read side
3799    /// stayed `<models_dir>/../models.json` — and `models_dir` is deliberately
3800    /// the machine-shared weights cache, which does NOT move. So against a
3801    /// relocated daemon the registration was persisted to
3802    /// `$CAR_HOME/models.json`, the next boot read `~/.car/models.json`, and
3803    /// the model silently never appeared. No error anywhere; the handler even
3804    /// reported the path it had written.
3805    #[test]
3806    fn a_registration_written_under_car_home_is_the_file_the_registry_reads() {
3807        let _environment = crate::openrouter::test_environment_scope();
3808        let prior = std::env::var_os(car_home::ENV_VAR);
3809
3810        let state_root = TempDir::new().unwrap();
3811        // Deliberately NOT inside the state root — this stands in for the
3812        // machine-shared `~/.car/models`, which stays put under `CAR_HOME`.
3813        let weights = TempDir::new().unwrap();
3814        let models_dir = weights.path().join("models");
3815        std::fs::create_dir_all(&models_dir).unwrap();
3816
3817        unsafe { std::env::set_var(car_home::ENV_VAR, state_root.path()) };
3818
3819        // Exactly what `handle_models_register` resolves and writes.
3820        let write_path = user_config_path().expect("CAR_HOME must resolve a models.json path");
3821        assert_eq!(write_path, state_root.path().join(USER_MODELS_FILE));
3822        let registered = test_generate_schema(
3823            "user/relocated-daemon-model",
3824            "relocated-daemon-model",
3825            ModelSource::RemoteApi {
3826                endpoint: "https://relocated.example/v1".into(),
3827                api_key_env: "RELOCATED_DAEMON_MODEL_TEST_KEY".into(),
3828                api_key_envs: vec![],
3829                api_version: None,
3830                protocol: crate::schema::ApiProtocol::OpenAiCompat,
3831            },
3832        );
3833        std::fs::write(
3834            &write_path,
3835            serde_json::to_vec_pretty(&vec![registered]).unwrap(),
3836        )
3837        .unwrap();
3838
3839        // …and exactly what the next daemon boot constructs.
3840        let registry = UnifiedRegistry::new(models_dir.clone());
3841
3842        match prior {
3843            Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
3844            None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
3845        }
3846
3847        assert!(
3848            registry.get("user/relocated-daemon-model").is_some(),
3849            "the registry must load the models.json that `models.register` wrote; \
3850             it looked at {} instead",
3851            registry.user_config_path.display(),
3852        );
3853        assert_eq!(registry.user_config_path, write_path);
3854        assert!(
3855            !weights.path().join(USER_MODELS_FILE).exists(),
3856            "nothing may be written beside the shared weights cache",
3857        );
3858    }
3859
3860    #[test]
3861    fn ready_without_download_is_strict_for_local_model_files() {
3862        let tmp = TempDir::new().unwrap();
3863        let models = tmp.path().join("models");
3864        let mut reg = UnifiedRegistry::new_empty(models.clone());
3865        reg.register(test_generate_schema(
3866            "local/test",
3867            "TestLocal",
3868            ModelSource::Local {
3869                hf_repo: "example/repo".into(),
3870                hf_filename: "model.gguf".into(),
3871                tokenizer_repo: "example/repo".into(),
3872            },
3873        ));
3874
3875        assert_eq!(reg.ready_without_download("local/test"), Some(false));
3876
3877        let dir = models.join("TestLocal");
3878        std::fs::create_dir_all(&dir).unwrap();
3879        std::fs::write(dir.join("model.gguf"), b"weights").unwrap();
3880        assert_eq!(
3881            reg.ready_without_download("local/test"),
3882            Some(false),
3883            "tokenizer is required too"
3884        );
3885        std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
3886        assert_eq!(reg.ready_without_download("local/test"), Some(true));
3887    }
3888
3889    #[test]
3890    fn ready_without_download_rejects_mlx_config_only_stub() {
3891        let tmp = TempDir::new().unwrap();
3892        let models = tmp.path().join("models");
3893        let mut reg = UnifiedRegistry::new_empty(models.clone());
3894        reg.register(test_generate_schema(
3895            "mlx/test",
3896            "TestMlx",
3897            ModelSource::Mlx {
3898                hf_repo: "example/repo".into(),
3899                hf_weight_file: None,
3900            },
3901        ));
3902
3903        let dir = models.join("TestMlx");
3904        std::fs::create_dir_all(&dir).unwrap();
3905        std::fs::write(dir.join("config.json"), "{}").unwrap();
3906        std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
3907        assert_eq!(
3908            reg.ready_without_download("mlx/test"),
3909            Some(false),
3910            "config/tokenizer stubs must not start assistant inference"
3911        );
3912
3913        std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
3914        assert_eq!(reg.ready_without_download("mlx/test"), Some(true));
3915    }
3916
3917    fn write_mlx_dir(root: &Path, name: &str, model_type: &str) {
3918        let dir = root.join(name);
3919        std::fs::create_dir_all(&dir).unwrap();
3920        std::fs::write(
3921            dir.join("config.json"),
3922            serde_json::json!({
3923                "model_type": model_type,
3924                "max_position_embeddings": 40_960,
3925                "quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" },
3926            })
3927            .to_string(),
3928        )
3929        .unwrap();
3930        std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
3931    }
3932
3933    #[test]
3934    fn synthesize_local_schema_classifies_by_name_and_arch() {
3935        let tmp = TempDir::new().unwrap();
3936        let root = tmp.path();
3937
3938        write_mlx_dir(root, "MyCustom-Qwen3-7B", "qwen3");
3939        let gen = synthesize_local_schema("MyCustom-Qwen3-7B", &root.join("MyCustom-Qwen3-7B"))
3940            .expect("text LLM should be recognized");
3941        assert_eq!(
3942            gen.capabilities,
3943            vec![
3944                ModelCapability::Generate,
3945                ModelCapability::Code,
3946                ModelCapability::Reasoning
3947            ]
3948        );
3949        assert_eq!(gen.context_length, 40_960);
3950        assert_eq!(gen.provider, "local");
3951        assert!(matches!(gen.source, ModelSource::Mlx { .. }));
3952
3953        write_mlx_dir(root, "Some-Embedding-0.6B", "qwen3");
3954        let emb = synthesize_local_schema("Some-Embedding-0.6B", &root.join("Some-Embedding-0.6B"))
3955            .expect("embedding model recognized");
3956        assert_eq!(emb.capabilities, vec![ModelCapability::Embed]);
3957
3958        // Unknown architecture: refuse rather than guess.
3959        write_mlx_dir(root, "Mystery-Net", "some_unknown_arch");
3960        assert!(synthesize_local_schema("Mystery-Net", &root.join("Mystery-Net")).is_none());
3961
3962        // Speech/vision/etc. names are skipped even with a valid LLM config.
3963        write_mlx_dir(root, "silero-vad-v6-mlx", "qwen3");
3964        assert!(
3965            synthesize_local_schema("silero-vad-v6-mlx", &root.join("silero-vad-v6-mlx")).is_none()
3966        );
3967
3968        // A bare directory with no weights is not a model.
3969        std::fs::create_dir_all(root.join("empty")).unwrap();
3970        assert!(synthesize_local_schema("empty", &root.join("empty")).is_none());
3971    }
3972
3973    /// A GGUF directory found by the scan carries no `hf_repo` — its weights
3974    /// are whatever the user put there. When the layout is not what the Candle
3975    /// backend reads, CAR used to attempt a download against the empty repo and
3976    /// report whatever `https://huggingface.co//…` returned, which explains
3977    /// nothing about the actual problem.
3978    #[test]
3979    fn a_scanned_gguf_directory_diagnoses_instead_of_downloading_from_nowhere() {
3980        let _environment = crate::openrouter::test_environment_scope();
3981        let tmp = TempDir::new().unwrap();
3982        let models = tmp.path().join("models");
3983        let dir = models.join("Dropped-In-Llama");
3984        std::fs::create_dir_all(&dir).unwrap();
3985        // A real user drop-in: the file keeps the name it was published under.
3986        std::fs::write(dir.join("Llama-3-8B-Q4_K_M.gguf"), b"weights").unwrap();
3987
3988        // Constructed outside the runtime: `new_with_state_root` ends in
3989        // `refresh_availability`, which blocks.
3990        let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
3991        let err = tokio::runtime::Runtime::new()
3992            .unwrap()
3993            .block_on(reg.ensure_local("Dropped-In-Llama"))
3994            .expect_err("an unloadable layout must not report success");
3995        let err = err.to_string();
3996
3997        assert!(err.contains("model.gguf"), "must name what it reads: {err}");
3998        assert!(
3999            err.contains("tokenizer.json"),
4000            "must name the missing tokenizer too: {err}"
4001        );
4002        assert!(
4003            !err.contains("huggingface.co//"),
4004            "must not have tried to fetch from an empty repo: {err}"
4005        );
4006    }
4007
4008    #[test]
4009    fn discovery_registers_uncatalogued_local_model() {
4010        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
4011        // which WRITES the process-global gateway observation. Full reasoning
4012        // on `TestRegistry` in this crate; family in
4013        // docs/solutions/process-global-state-in-tests.md.
4014        let _environment = crate::openrouter::test_environment_scope();
4015        let tmp = TempDir::new().unwrap();
4016        let models = tmp.path().join("models");
4017        std::fs::create_dir_all(&models).unwrap();
4018        write_mlx_dir(&models, "Totally-Custom-Llama-3B", "llama");
4019
4020        let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
4021        let found = reg
4022            .list()
4023            .into_iter()
4024            .find(|m| m.name == "Totally-Custom-Llama-3B");
4025        assert!(
4026            found.is_some(),
4027            "uncatalogued on-disk model should be registered"
4028        );
4029        assert!(found.unwrap().tags.iter().any(|t| t == "auto-discovered"));
4030    }
4031
4032    #[test]
4033    fn signed_catalog_cannot_shadow_builtin_exact_id() {
4034        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
4035        // which WRITES the process-global gateway observation. Full reasoning
4036        // on `TestRegistry` in this crate; family in
4037        // docs/solutions/process-global-state-in-tests.md.
4038        let _environment = crate::openrouter::test_environment_scope();
4039        // A signature authenticates the catalog publisher, but it does not
4040        // authorize changing a project-owned exact id compiled into this CAR
4041        // build. An additive signed row may not shadow a builtin.
4042        let tmp = TempDir::new().unwrap();
4043        let models_dir = tmp.path().join("models");
4044
4045        let builtin = builtin_catalog();
4046        let mut overriding = builtin.first().expect("a built-in model").clone();
4047        let target_id = overriding.id.clone();
4048        overriding.name = "REPLACED-BY-CATALOG".into();
4049
4050        let (verified, public_key) = crate::catalog::signed_test_catalog(
4051            crate::catalog::CatalogDoc {
4052                version: 1,
4053                models: vec![overriding],
4054            },
4055            51,
4056        );
4057        crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
4058
4059        // new() loads the verified cache additively after the built-ins.
4060        let reg = UnifiedRegistry::new_with_catalog_public_key(
4061            tmp.path().to_path_buf(),
4062            models_dir,
4063            Some(public_key.as_str()),
4064        );
4065        assert_eq!(
4066            reg.get(&target_id).map(|m| m.name.as_str()),
4067            Some(builtin.first().unwrap().name.as_str()),
4068            "a signed cache row must not replace a builtin exact id"
4069        );
4070    }
4071
4072    #[test]
4073    fn user_model_cannot_shadow_project_owned_exact_id() {
4074        let mut reg = test_registry();
4075        let original = builtin_catalog().first().expect("a builtin").clone();
4076        let mut forged = original.clone();
4077        forged.name = "USER-SHADOW".into();
4078
4079        reg.register_user_model(forged);
4080
4081        assert_eq!(
4082            reg.get(&original.id).map(|model| model.name.as_str()),
4083            Some(original.name.as_str()),
4084            "a user row must not replace a project-owned exact id"
4085        );
4086    }
4087
4088    #[test]
4089    fn legacy_unsigned_catalog_cache_cannot_replace_builtin() {
4090        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
4091        // which WRITES the process-global gateway observation. Full reasoning
4092        // on `TestRegistry` in this crate; family in
4093        // docs/solutions/process-global-state-in-tests.md.
4094        let _environment = crate::openrouter::test_environment_scope();
4095        let tmp = TempDir::new().unwrap();
4096        let models_dir = tmp.path().join("models");
4097        let builtin = builtin_catalog();
4098        let original = builtin.first().expect("a built-in model");
4099        let mut forged = original.clone();
4100        forged.name = "FORGED-UNSIGNED-CATALOG".into();
4101        let path = crate::catalog::cache_path(tmp.path());
4102        std::fs::write(
4103            &path,
4104            serde_json::to_vec_pretty(&crate::catalog::CatalogDoc {
4105                version: u64::MAX,
4106                models: vec![forged],
4107            })
4108            .unwrap(),
4109        )
4110        .unwrap();
4111
4112        let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
4113        assert_eq!(
4114            reg.get(&original.id).map(|model| model.name.as_str()),
4115            Some(original.name.as_str()),
4116            "legacy unsigned cache JSON must fail closed and preserve the built-in"
4117        );
4118    }
4119
4120    #[test]
4121    fn tampered_signed_managed_row_preserves_builtin() {
4122        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
4123        // which WRITES the process-global gateway observation. Full reasoning
4124        // on `TestRegistry` in this crate; family in
4125        // docs/solutions/process-global-state-in-tests.md.
4126        let _environment = crate::openrouter::test_environment_scope();
4127        let tmp = TempDir::new().unwrap();
4128        let models_dir = tmp.path().join("models");
4129        let original = builtin_catalog()
4130            .into_iter()
4131            .find(|model| model.id == "parslee/openrouter/frontier-general")
4132            .expect("managed frontier alias");
4133        let mut forged = original.clone();
4134        forged.name = "SIGNED-THEN-TAMPERED-MANAGED".into();
4135        let (verified, public_key) = crate::catalog::signed_test_catalog(
4136            crate::catalog::CatalogDoc {
4137                version: 9,
4138                models: vec![forged],
4139            },
4140            52,
4141        );
4142        let path = crate::catalog::cache_path(tmp.path());
4143        crate::catalog::save_verified(&path, &verified).unwrap();
4144        let cache = std::fs::read_to_string(&path)
4145            .unwrap()
4146            .replace("SIGNED-THEN-TAMPERED-MANAGED", "ATTACKER-MUTATION");
4147        std::fs::write(&path, cache).unwrap();
4148
4149        let reg = UnifiedRegistry::new_with_catalog_public_key(
4150            tmp.path().to_path_buf(),
4151            models_dir,
4152            Some(public_key.as_str()),
4153        );
4154        assert_eq!(
4155            reg.get(&original.id).map(|model| model.name.as_str()),
4156            Some(original.name.as_str()),
4157            "a tampered same-id managed row must fail verification and preserve the builtin"
4158        );
4159    }
4160
4161    #[test]
4162    fn builtin_catalog_loads() {
4163        let reg = test_registry();
4164        let all = reg.list();
4165        assert_eq!(all.len(), builtin_catalog().len());
4166    }
4167
4168    #[test]
4169    fn shipped_supervised_vllm_models_use_the_managed_source_contract() {
4170        let managed = builtin_catalog()
4171            .into_iter()
4172            .filter(|model| model.id.starts_with("vllm-mlx/"))
4173            .collect::<Vec<_>>();
4174        assert_eq!(managed.len(), 8);
4175        assert!(managed.iter().all(ModelSchema::is_car_managed_vllm_mlx));
4176        assert!(managed.iter().all(ModelSchema::downloads_weights));
4177    }
4178
4179    /// #137: a model tagged `requires-mlx-vlm` must report
4180    /// `available: true` if and only if `mlx_vlm_cli::is_available()`
4181    /// returns true. Without this, registry consumers (FFI
4182    /// `listModelsUnified`, the tray Models submenu, agent routing)
4183    /// see a model as available, the user picks it, and inference
4184    /// bails with `mlx-vlm CLI not found on PATH`.
4185    ///
4186    /// The probe is environmental — runs the same check on the host
4187    /// the test executes on. CI usually doesn't have `mlx_vlm`
4188    /// installed → expected unavailable; a dev box with it installed
4189    /// → expected available. Either way, the registry tracks the
4190    /// runtime probe.
4191    #[test]
4192    fn mlx_vlm_models_reflect_runtime_availability() {
4193        let reg = test_registry();
4194        let mlx_vlm_models: Vec<&ModelSchema> = reg
4195            .list()
4196            .into_iter()
4197            .filter(|m| m.tags.iter().any(|t| t == "requires-mlx-vlm"))
4198            .collect();
4199        assert!(
4200            !mlx_vlm_models.is_empty(),
4201            "catalog should contain at least one model tagged \
4202             `requires-mlx-vlm` — otherwise this regression has \
4203             nothing to guard"
4204        );
4205
4206        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4207        let expected = crate::backend::mlx_vlm_cli::is_available();
4208        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4209        let expected = false;
4210
4211        for m in mlx_vlm_models {
4212            assert_eq!(
4213                m.available, expected,
4214                "model {} `available` field should reflect \
4215                 mlx_vlm CLI presence (expected {expected}, got {})",
4216                m.id, m.available
4217            );
4218        }
4219    }
4220
4221    /// F1 (Parslee-ai/car#231 §7.1): MLX models must report
4222    /// `available: false` on any non-Apple-Silicon-with-MLX build target.
4223    /// Without this, the Windows / Linux / Intel-Mac router happily
4224    /// adds MLX entries to fallback chains and inference fails at
4225    /// dispatch with "model not found", producing a 7-deep cascade
4226    /// of useless errors on a fresh install.
4227    ///
4228    /// The test runs on every platform. On macOS arm64 (default-features),
4229    /// at least one MLX model with an `hf_repo` should report available;
4230    /// on Linux / Windows / Intel-Mac / `car_skip_mlx`, every plain MLX
4231    /// model must report unavailable.
4232    #[test]
4233    fn mlx_models_unavailable_on_non_mlx_targets() {
4234        let reg = test_registry();
4235        let mlx_models: Vec<&ModelSchema> = reg
4236            .list()
4237            .into_iter()
4238            .filter(|m| {
4239                m.is_mlx()
4240                    // Exclude `requires-mlx-vlm` and `speech` — those have
4241                    // their own availability logic (mlx_vlm CLI probe,
4242                    // speech_mlx_available). The plain MLX models are
4243                    // what §7.1 surfaced as broken on Windows.
4244                    && !m.tags.iter().any(|t| t == "requires-mlx-vlm")
4245                    && !m.tags.contains(&"speech".to_string())
4246            })
4247            .collect();
4248        assert!(
4249            !mlx_models.is_empty(),
4250            "catalog should contain at least one plain MLX model — \
4251             otherwise this F1 regression guard has nothing to guard"
4252        );
4253
4254        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4255        {
4256            // On a real MLX target, models with an `hf_repo` should be
4257            // marked available (the `ensure_local()` lazy-download path).
4258            // At least one must qualify.
4259            let any_available = mlx_models.iter().any(|m| m.available);
4260            assert!(
4261                any_available,
4262                "on macOS arm64 with MLX enabled, at least one plain MLX \
4263                 model with hf_repo should be available — none were"
4264            );
4265        }
4266        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4267        {
4268            // On every other target, MLX models cannot execute, so the
4269            // registry must mark them all unavailable.
4270            for m in &mlx_models {
4271                assert!(
4272                    !m.available,
4273                    "MLX model {} is marked available on a non-MLX target — \
4274                     the adaptive router will add it to fallback chains \
4275                     and dispatch will fail (Parslee-ai/car#231 §7.1)",
4276                    m.id
4277                );
4278            }
4279        }
4280    }
4281
4282    /// Embedded JSON must parse cleanly — if it doesn't, the runtime
4283    /// would panic on first registry load. Catch it in CI instead.
4284    #[test]
4285    fn builtin_catalog_json_parses() {
4286        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON)
4287            .expect("builtin_catalog.json must be valid ModelSchema array");
4288        assert!(
4289            !catalog.is_empty(),
4290            "embedded catalog has no entries — that's almost certainly wrong"
4291        );
4292
4293        let mut seen = std::collections::HashSet::new();
4294        for entry in &catalog {
4295            assert!(
4296                seen.insert(entry.id.clone()),
4297                "duplicate id in builtin_catalog.json: {}",
4298                entry.id
4299            );
4300        }
4301    }
4302
4303    #[test]
4304    fn codex_subscription_row_has_pinnable_text_only_identity() {
4305        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
4306        let row = catalog
4307            .iter()
4308            .find(|model| model.id == "openai/gpt-5.6-sol:high")
4309            .expect("catalog publishes the Codex subscription identity");
4310        assert_eq!(row.name, "gpt-5.6-sol:high");
4311        assert!(matches!(
4312            &row.source,
4313            ModelSource::CodexCli { model } if model == "gpt-5.6-sol:high"
4314        ));
4315        assert!(row.has_capability(ModelCapability::Generate));
4316        assert!(row.has_capability(ModelCapability::Reasoning));
4317        assert!(!row.has_capability(ModelCapability::ToolUse));
4318        assert!(!row.has_capability(ModelCapability::MultiToolCall));
4319        assert!(!row.has_capability(ModelCapability::Vision));
4320        assert_eq!(row.supported_params, vec![GenerateParam::MaxTokens]);
4321        assert!(!row.downloads_weights());
4322    }
4323
4324    /// In-process Qwen3 models advertise tool capability because the local
4325    /// generate path now renders Qwen3's `<tools>` chat format and parses
4326    /// `<tool_call>` blocks back out (`tasks::generate::render_chat_prompt` /
4327    /// `parse_tool_calls`). This pins the catalog so the capability claim and
4328    /// the implementation stay in lockstep — if the in-process tool path is
4329    /// ever removed, this test should fail until the claim is dropped too.
4330    #[test]
4331    fn in_process_qwen3_models_declare_tool_use() {
4332        use crate::schema::ModelSource;
4333        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
4334        // The mid/large Qwen3 sizes are reliable tool-callers; the 0.6b/1.7b
4335        // tiers legitimately don't advertise tool_use.
4336        let tool_sizes = ["qwen3-4b", "qwen3-8b", "qwen3-30b-a3b"];
4337        let mut checked = 0;
4338        for entry in &catalog {
4339            let in_process = matches!(
4340                entry.source,
4341                ModelSource::Mlx { .. } | ModelSource::Local { .. }
4342            );
4343            if !in_process || !tool_sizes.iter().any(|s| entry.id.contains(s)) {
4344                continue;
4345            }
4346            assert!(
4347                entry.capabilities.contains(&ModelCapability::ToolUse),
4348                "in-process Qwen3 model {} should advertise ToolUse — the local \
4349                 generate path renders/parses tool calls",
4350                entry.id
4351            );
4352            checked += 1;
4353        }
4354        assert_eq!(
4355            checked, 6,
4356            "expected 6 in-process tool-capable Qwen3 entries (3 mlx + 3 gguf)"
4357        );
4358    }
4359
4360    #[test]
4361    fn public_benchmarks_round_trip_through_model_info() {
4362        use crate::schema::BenchmarkScore;
4363        let mut reg = test_registry();
4364        let mut schema = reg
4365            .find_by_name("Qwen3-4B")
4366            .expect("catalog has Qwen3-4B")
4367            .clone();
4368        schema.id = "test/qwen3-4b-with-bench".into();
4369        schema.public_benchmarks = vec![
4370            BenchmarkScore {
4371                name: "MMLU-Pro".into(),
4372                score: 0.482,
4373                harness: Some("5-shot CoT".into()),
4374                source_url: Some("https://example.invalid/qwen3-4b-card".into()),
4375                measured_at: Some("2025-08-12".into()),
4376            },
4377            BenchmarkScore {
4378                name: "HumanEval".into(),
4379                score: 0.713,
4380                harness: Some("pass@1".into()),
4381                source_url: None,
4382                measured_at: None,
4383            },
4384        ];
4385        reg.register(schema);
4386
4387        let stored = reg
4388            .get("test/qwen3-4b-with-bench")
4389            .expect("registered model is retrievable");
4390        let info = ModelInfo::from(stored);
4391        assert_eq!(info.public_benchmarks.len(), 2);
4392
4393        // The serialized JSON shape is what the WS / FFI clients consume.
4394        let json = serde_json::to_string(&info).unwrap();
4395        assert!(json.contains("\"public_benchmarks\""));
4396        assert!(json.contains("\"MMLU-Pro\""));
4397        assert!(json.contains("\"5-shot CoT\""));
4398
4399        // Round-trip back through serde to confirm deserialization works.
4400        let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
4401        assert_eq!(decoded.public_benchmarks.len(), 2);
4402        assert_eq!(decoded.public_benchmarks[0].name, "MMLU-Pro");
4403        assert_eq!(decoded.public_benchmarks[1].name, "HumanEval");
4404    }
4405
4406    #[test]
4407    fn public_benchmarks_default_to_empty_when_absent_in_json() {
4408        // Older user-config JSON written before this field existed must
4409        // still deserialize cleanly into the new ModelSchema shape.
4410        let legacy_json = r#"{
4411            "id": "legacy/test:1",
4412            "name": "Legacy Test",
4413            "provider": "test",
4414            "family": "test",
4415            "version": "",
4416            "capabilities": ["generate"],
4417            "context_length": 4096,
4418            "param_count": "1B",
4419            "quantization": null,
4420            "performance": {},
4421            "cost": {},
4422            "source": { "type": "ollama", "model_tag": "legacy:1" },
4423            "tags": [],
4424            "supported_params": []
4425        }"#;
4426        let schema: ModelSchema = serde_json::from_str(legacy_json).unwrap();
4427        assert!(schema.public_benchmarks.is_empty());
4428    }
4429
4430    #[test]
4431    fn find_by_name() {
4432        let reg = test_registry();
4433        let m = reg.find_by_name("Qwen3-4B").unwrap();
4434        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4435        assert_eq!(m.id, "mlx/qwen3-4b:4bit");
4436        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4437        assert_eq!(m.id, "qwen/qwen3-4b:q4_k_m");
4438        assert!(m.has_capability(ModelCapability::Code));
4439    }
4440
4441    #[test]
4442    fn query_by_capability() {
4443        let reg = test_registry();
4444        let embed_models = reg.query_by_capability(ModelCapability::Embed);
4445        assert_eq!(embed_models.len(), 2);
4446        assert!(embed_models
4447            .iter()
4448            .any(|model| model.name == "Qwen3-Embedding-0.6B"));
4449        assert!(embed_models
4450            .iter()
4451            .any(|model| model.name == "Qwen3-Embedding-0.6B-MLX"));
4452    }
4453
4454    #[test]
4455    fn query_with_filter() {
4456        let reg = test_registry();
4457        let code_small = reg.query(&ModelFilter {
4458            capabilities: vec![ModelCapability::Code],
4459            max_size_mb: Some(3000),
4460            local_only: true,
4461            ..Default::default()
4462        });
4463        // Qwen3-1.7B, Qwen3-1.7B-MLX, Qwen3-4B, and Qwen3-4B-MLX fit and have Code capability.
4464        assert_eq!(code_small.len(), 4);
4465    }
4466
4467    #[test]
4468    fn register_remote() {
4469        let mut reg = test_registry();
4470        let initial_len = reg.list().len();
4471        let initial_reasoning_len = reg
4472            .query(&ModelFilter {
4473                capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
4474                ..Default::default()
4475            })
4476            .len();
4477        let remote = ModelSchema {
4478            id: "anthropic/claude-sonnet-4-6:latest".into(),
4479            name: "Claude Sonnet 4.6".into(),
4480            provider: "anthropic".into(),
4481            family: "claude-4".into(),
4482            version: "latest".into(),
4483            capabilities: vec![
4484                ModelCapability::Generate,
4485                ModelCapability::Code,
4486                ModelCapability::Reasoning,
4487                ModelCapability::ToolUse,
4488            ],
4489            context_length: 200000,
4490            max_output_tokens: None,
4491            param_count: String::new(),
4492            quantization: None,
4493            performance: PerformanceEnvelope {
4494                latency_p50_ms: Some(2000),
4495                ..Default::default()
4496            },
4497            cost: CostModel {
4498                input_per_mtok: Some(3.0),
4499                output_per_mtok: Some(15.0),
4500                ..Default::default()
4501            },
4502            source: ModelSource::RemoteApi {
4503                endpoint: "https://api.anthropic.com/v1/messages".into(),
4504                api_key_env: "ANTHROPIC_API_KEY".into(),
4505                api_key_envs: vec![],
4506                api_version: Some("2023-06-01".into()),
4507                protocol: ApiProtocol::Anthropic,
4508            },
4509            tags: vec![],
4510            supported_params: vec![],
4511            public_benchmarks: vec![],
4512            trust_tier: crate::schema::TrustTier::Curated,
4513            deprecated: false,
4514            available: false,
4515            weights_ready: false,
4516        };
4517
4518        reg.register(remote);
4519        // Same ID as builtin claude-sonnet-4-6 — replaces, count stays same
4520        assert_eq!(reg.list().len(), initial_len);
4521
4522        let reasoning = reg.query(&ModelFilter {
4523            capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
4524            ..Default::default()
4525        });
4526        // Replacing an existing remote slot should not change the reasoning/tool-use lineup size.
4527        assert_eq!(reasoning.len(), initial_reasoning_len);
4528    }
4529
4530    #[test]
4531    fn unregister() {
4532        let mut reg = test_registry();
4533        let initial_len = reg.list().len();
4534        let removed = reg.unregister("qwen/qwen3-0.6b:q8_0");
4535        assert!(removed.is_some());
4536        assert_eq!(reg.list().len(), initial_len - 1);
4537    }
4538
4539    #[test]
4540    fn speech_models_are_curated() {
4541        let reg = test_registry();
4542        let stt = reg.query_by_capability(ModelCapability::SpeechToText);
4543        let tts = reg.query_by_capability(ModelCapability::TextToSpeech);
4544        // Parakeet-TDT-MLX + Whisper-large-v3-turbo (cross-platform) + scribe_v1.
4545        assert_eq!(stt.len(), 3);
4546        // Kokoro-6bit/bf16 + Windows-Speech (OS, Windows-only) + Qwen3-TTS + eleven.
4547        assert_eq!(tts.len(), 5);
4548        // whisper.cpp STT is a first-class catalog citizen — the cross-platform
4549        // local STT that runs where MLX (Apple-only) can't.
4550        let whisper = stt
4551            .iter()
4552            .find(|m| m.name == "Whisper-large-v3-turbo-q5_0")
4553            .expect("whisper STT model should be curated");
4554        assert!(whisper.is_local());
4555        assert!(matches!(
4556            whisper.source,
4557            crate::schema::ModelSource::WhisperCpp { .. }
4558        ));
4559    }
4560
4561    #[test]
4562    fn qwen_8b_variants_keep_tool_use_consistent() {
4563        // The GGUF and MLX twins of Qwen3-8B must agree on tool capability, and
4564        // both advertise it: the in-process generate path renders Qwen3's
4565        // <tools> chat format and parses <tool_call> blocks back out (see
4566        // tasks::generate::render_chat_prompt / parse_tool_calls).
4567        let reg = test_registry();
4568        for name in ["Qwen3-8B", "Qwen3-8B-MLX"] {
4569            let model = reg.find_by_name(name).expect("model should exist");
4570            assert!(model.has_capability(ModelCapability::ToolUse));
4571            assert!(model.has_capability(ModelCapability::MultiToolCall));
4572        }
4573    }
4574
4575    #[test]
4576    fn mac_name_resolution_prefers_mlx_siblings() {
4577        // Only used inside the aarch64-macos cfg below; non-mac targets
4578        // keep the test as a smoke compile.
4579        #[allow(unused_variables)]
4580        let reg = test_registry();
4581        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4582        {
4583            assert_eq!(
4584                reg.find_by_name("Qwen3-0.6B").unwrap().id,
4585                "mlx/qwen3-0.6b:6bit"
4586            );
4587            assert_eq!(
4588                reg.find_by_name("Qwen3-1.7B").unwrap().id,
4589                "mlx/qwen3-1.7b:3bit"
4590            );
4591            assert_eq!(
4592                reg.find_by_name("Qwen3-Embedding-0.6B").unwrap().id,
4593                "mlx/qwen3-embedding-0.6b:mxfp8"
4594            );
4595        }
4596    }
4597
4598    #[test]
4599    fn remote_multimodal_models_are_curated_as_vision_capable() {
4600        let reg = test_registry();
4601        for name in [
4602            "claude-opus-4-7",
4603            "claude-opus-4-6",
4604            "claude-sonnet-4-6",
4605            "claude-haiku-4-5",
4606            "gpt-5.4",
4607            "gpt-5.4-mini",
4608            "o3",
4609            "o4-mini",
4610            "gpt-4.1-mini",
4611            "gemini-2.5-pro",
4612            "gemini-2.5-flash",
4613        ] {
4614            let model = reg.find_by_name(name).expect("model should exist");
4615            assert!(
4616                model.has_capability(ModelCapability::Vision),
4617                "{name} should be curated as vision-capable"
4618            );
4619        }
4620    }
4621
4622    #[test]
4623    fn qwen25vl_entries_are_replaced_by_qwen3vl_in_builtin_catalog() {
4624        let reg = test_registry();
4625
4626        let stale_ids = [
4627            // Native MLX text tower can't tokenize images — never advertise.
4628            "mlx/qwen2.5-vl-3b:4bit",
4629            "mlx/qwen2.5-vl-7b:4bit",
4630            // Qwen2.5-VL is superseded by Qwen3-VL; drop the mlx-vlm CLI
4631            // catalog entries so callers route to the upgraded family.
4632            "mlx-vlm/qwen2.5-vl-3b:4bit",
4633            "mlx-vlm/qwen2.5-vl-7b:4bit",
4634            // Same supersession applies to the vLLM-MLX route.
4635            "vllm-mlx/qwen2.5-vl-3b:4bit",
4636        ];
4637        for id in stale_ids {
4638            assert!(
4639                reg.get(id).is_none(),
4640                "{id} is superseded by Qwen3-VL; the catalog must not advertise it"
4641            );
4642        }
4643
4644        let vision_ids: Vec<&str> = reg
4645            .query_by_capability(ModelCapability::Vision)
4646            .into_iter()
4647            .map(|model| model.id.as_str())
4648            .collect();
4649        for stale in stale_ids {
4650            assert!(
4651                !vision_ids.contains(&stale),
4652                "{stale} must not be reachable through the Vision capability index"
4653            );
4654        }
4655        assert!(
4656            vision_ids.contains(&"mlx-vlm/qwen3-vl-2b:bf16"),
4657            "Qwen3-VL is the supported local VL family and must route as Vision"
4658        );
4659    }
4660
4661    #[test]
4662    fn gemini_models_are_curated_for_multimodal_tool_use() {
4663        let reg = test_registry();
4664        for name in ["gemini-2.5-pro", "gemini-2.5-flash"] {
4665            let model = reg.find_by_name(name).expect("model should exist");
4666            assert!(model.has_capability(ModelCapability::Vision));
4667            assert!(model.has_capability(ModelCapability::ToolUse));
4668            assert!(model.has_capability(ModelCapability::MultiToolCall));
4669        }
4670    }
4671
4672    #[test]
4673    fn model_info_publishes_declared_prices_and_keeps_unpriced_distinct_from_free() {
4674        let reg = test_registry();
4675
4676        // A priced remote row publishes every declared rate plus its tiers.
4677        let opus = reg
4678            .list()
4679            .into_iter()
4680            .find(|m| m.id == "openrouter/anthropic/claude-opus-4.8")
4681            .map(ModelInfo::from)
4682            .expect("curated opus-4.8 row is present on first boot");
4683        assert_eq!(opus.cost.input_per_mtok, Some(5.0));
4684        assert_eq!(opus.cost.output_per_mtok, Some(25.0));
4685        assert_eq!(opus.cost.cache_read_input_per_mtok, Some(0.5));
4686        assert_eq!(opus.cost.cache_write_input_per_mtok, Some(6.25));
4687
4688        // Tiered pricing survives the projection where a model declares it.
4689        let gpt = reg
4690            .list()
4691            .into_iter()
4692            .find(|m| m.id == "openrouter/openai/gpt-5.4")
4693            .map(ModelInfo::from)
4694            .expect("curated gpt-5.4 row");
4695        assert_eq!(gpt.cost.pricing_tiers.len(), 1);
4696        assert_eq!(gpt.cost.prices_for(272_000).input_per_mtok, Some(5.0));
4697
4698        // A local model that declares no prices reports them ABSENT. `null`
4699        // and `0.0` are different facts and must stay different on the wire.
4700        let local = reg
4701            .list()
4702            .into_iter()
4703            .find(|m| m.is_local() && m.cost.input_per_mtok.is_none())
4704            .map(ModelInfo::from)
4705            .expect("the built-in catalog ships unpriced local models");
4706        let json = serde_json::to_value(&local).unwrap();
4707        assert!(json["cost"]["input_per_mtok"].is_null());
4708        assert!(json["cost"]["output_per_mtok"].is_null());
4709        assert_ne!(json["cost"]["input_per_mtok"], serde_json::json!(0.0));
4710    }
4711
4712    #[test]
4713    fn a_hand_registered_copy_of_a_curated_id_does_not_double_the_row() {
4714        let mut reg = test_registry();
4715        let id = "openrouter/anthropic/claude-opus-4.8";
4716        assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
4717
4718        // The runtime registry is keyed by id, so a user registration of the
4719        // same id replaces the curated row rather than appending a duplicate.
4720        let mut copy = reg
4721            .list()
4722            .into_iter()
4723            .find(|m| m.id == id)
4724            .cloned()
4725            .expect("curated row");
4726        copy.name = "hand-registered".into();
4727        reg.register_user_model(copy);
4728        assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
4729    }
4730
4731    /// Scoped to `ModelInfo` — the `models.list_unified` projection — on
4732    /// purpose. `models.search` wraps this same struct but adds `family`,
4733    /// which for a managed alias names the upstream model family and is
4734    /// matched against by queries. That is pre-existing (`frontier-deep`
4735    /// already carries `claude-4.6`) and out of scope here; this test locks
4736    /// the surface the guarantee actually holds on rather than implying a
4737    /// registry-wide one it does not.
4738    #[test]
4739    fn managed_alias_publishes_prices_without_disclosing_the_upstream_id_in_the_catalog_view() {
4740        let reg = test_registry();
4741        let alias = reg
4742            .list()
4743            .into_iter()
4744            .find(|m| m.id == "parslee/openrouter/frontier-deep-next")
4745            .map(ModelInfo::from)
4746            .expect("managed alias for the new curated row");
4747
4748        assert_eq!(alias.cost.input_per_mtok, Some(5.0));
4749        assert_eq!(alias.cost.output_per_mtok, Some(25.0));
4750        assert_eq!(alias.cost.cache_read_input_per_mtok, Some(0.5));
4751        assert_eq!(alias.cost.cache_write_input_per_mtok, Some(6.25));
4752
4753        let wire = serde_json::to_string(&alias).unwrap();
4754        assert!(!wire.contains("claude-opus-4.8"));
4755        assert!(!wire.contains("anthropic/"));
4756    }
4757
4758    #[test]
4759    fn model_info_from_an_older_daemon_without_cost_still_parses() {
4760        // A newly built client must not fail to read a catalog produced before
4761        // `cost` existed; the row deserializes with an all-absent cost.
4762        let legacy = serde_json::json!({
4763            "id": "legacy/model",
4764            "name": "legacy",
4765            "provider": "legacy",
4766            "capabilities": ["generate"],
4767            "param_count": "",
4768            "size_mb": 0,
4769            "context_length": 8192,
4770            "available": true,
4771            "is_local": false
4772        });
4773        let info: ModelInfo = serde_json::from_value(legacy).expect("older catalog row parses");
4774        assert!(info.cost.input_per_mtok.is_none());
4775        assert!(info.cost.output_per_mtok.is_none());
4776        assert!(info.cost.pricing_tiers.is_empty());
4777        assert!(info.max_output_tokens.is_none());
4778        assert!(info.car_enabled, "legacy rows default to enabled");
4779        assert!(!info.can_remove);
4780        assert!(!info.in_use);
4781        assert!(info.management_evidence.is_none());
4782    }
4783
4784    #[test]
4785    fn visual_generation_models_are_curated() {
4786        let reg = test_registry();
4787        assert_eq!(
4788            reg.query_by_capability(ModelCapability::ImageGeneration)
4789                .len(),
4790            1
4791        );
4792        assert_eq!(
4793            reg.query_by_capability(ModelCapability::VideoGeneration)
4794                .len(),
4795            1
4796        );
4797    }
4798}
4799
4800/// Structural validation of the built-in catalog.
4801///
4802/// The catalog is hand-edited JSON compiled into the binary, so a typo in it is
4803/// a shipped bug that no compiler catches. It went seven weeks without a new
4804/// text model while the open-weight world moved through three generations, and
4805/// the entry added to end that drought carried `param_count: "MoE"` — a string
4806/// the scorer cannot parse — which nothing would have caught. These are the
4807/// invariants the rest of the code already assumes.
4808#[cfg(test)]
4809mod builtin_catalog_validation {
4810    use super::*;
4811    use crate::schema::ModelSource;
4812
4813    /// Sources whose weights CAR (or a runtime CAR supervises) actually loads.
4814    fn weight_repo(source: &ModelSource) -> Option<&str> {
4815        match source {
4816            ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
4817            ModelSource::Local { hf_repo, .. } => Some(hf_repo),
4818            ModelSource::ManagedVllmMlx { hf_repo, .. } => Some(hf_repo),
4819            _ => None,
4820        }
4821    }
4822
4823    #[test]
4824    fn ids_are_unique() {
4825        let catalog = builtin_catalog();
4826        let mut seen: Vec<&str> = Vec::new();
4827        for model in &catalog {
4828            assert!(
4829                !seen.contains(&model.id.as_str()),
4830                "duplicate catalog id `{}` — the later entry silently shadows the earlier",
4831                model.id
4832            );
4833            seen.push(&model.id);
4834        }
4835    }
4836
4837    #[test]
4838    fn exact_frontier_rows_lock_native_selectors_and_digests() {
4839        let catalog = builtin_catalog();
4840        for (id, name, version, expected_digest) in [
4841            (
4842                "openai/gpt-5.5-2026-04-23",
4843                "gpt-5.5-2026-04-23",
4844                "2026-04-23",
4845                "aa1b0741114e6d9d0e1a758a3ab76005fe55dbcfe050d6a10a5dc37675b07b8f",
4846            ),
4847            (
4848                "anthropic/claude-opus-4-8",
4849                "claude-opus-4-8",
4850                "4.8",
4851                "10504959e51dc76c3563df91ae2eaba57cf814834ecd657232c50f264f9e735e",
4852            ),
4853            (
4854                "openai/gpt-5.6-sol:high",
4855                "gpt-5.6-sol:high",
4856                "latest",
4857                "6cc4a9a80708dbb99c8e4edb6c13be46b2b727a798cb354dcc5598d9004c4acd",
4858            ),
4859        ] {
4860            let row = catalog
4861                .iter()
4862                .find(|model| model.id == id)
4863                .unwrap_or_else(|| panic!("missing exact production row {id}"));
4864            assert_eq!(row.name, name);
4865            assert_eq!(row.version, version);
4866            assert_eq!(
4867                crate::catalog_identity::row_digest(row).unwrap(),
4868                expected_digest,
4869                "CAR row digest drifted for {id}"
4870            );
4871        }
4872    }
4873
4874    #[test]
4875    fn weight_repos_are_well_formed_huggingface_ids() {
4876        for model in builtin_catalog() {
4877            let Some(repo) = weight_repo(&model.source) else {
4878                continue;
4879            };
4880            assert_eq!(
4881                repo.split('/').count(),
4882                2,
4883                "{}: `{repo}` is not an `org/name` HuggingFace id",
4884                model.id
4885            );
4886            assert!(
4887                !repo.split('/').any(str::is_empty),
4888                "{}: `{repo}` has an empty path segment",
4889                model.id
4890            );
4891            assert!(
4892                !repo.contains(char::is_whitespace),
4893                "{}: `{repo}` contains whitespace",
4894                model.id
4895            );
4896        }
4897    }
4898
4899    /// `param_billions_total` parses a leading number; anything else silently
4900    /// falls back to a size estimate, so an unparseable value is a scoring bug
4901    /// that presents as a mysteriously bad recommendation.
4902    #[test]
4903    fn param_counts_are_parseable_or_deliberately_empty() {
4904        for model in builtin_catalog() {
4905            if weight_repo(&model.source).is_none() || model.param_count.is_empty() {
4906                continue;
4907            }
4908            assert!(
4909                model.param_count.starts_with(|c: char| c.is_ascii_digit()),
4910                "{}: param_count `{}` does not start with a number, so the quality \
4911                 prior cannot read it — leave it empty rather than descriptive",
4912                model.id,
4913                model.param_count
4914            );
4915        }
4916    }
4917
4918    /// CAR supervision is an explicit source contract. Endpoint topology must
4919    /// never be used to infer ownership.
4920    #[test]
4921    fn catalog_vllm_mlx_entries_use_explicit_managed_ownership() {
4922        for model in builtin_catalog() {
4923            if !model.is_vllm_mlx() {
4924                continue;
4925            }
4926            assert!(
4927                model.is_car_managed_vllm_mlx(),
4928                "{}: a CAR-supervised catalog row must opt into ManagedVllmMlx; \
4929                 loopback alone cannot confer ownership",
4930                model.id
4931            );
4932        }
4933    }
4934
4935    #[test]
4936    fn generate_capable_models_declare_a_context_window() {
4937        for model in builtin_catalog() {
4938            if !model.has_capability(crate::schema::ModelCapability::Generate) {
4939                continue;
4940            }
4941            assert!(
4942                model.context_length > 0,
4943                "{}: a generate-capable model with no context_length breaks budget sizing",
4944                model.id
4945            );
4946        }
4947    }
4948
4949    #[test]
4950    fn every_entry_declares_at_least_one_capability() {
4951        for model in builtin_catalog() {
4952            assert!(
4953                !model.capabilities.is_empty(),
4954                "{}: an entry with no capabilities can never be routed to",
4955                model.id
4956            );
4957        }
4958    }
4959}
4960
4961#[cfg(test)]
4962mod gguf_quantization_tests {
4963    use crate::schema::{QuantScheme, Quantization};
4964
4965    fn quantization_from_gguf_filename(name: &str) -> Option<Quantization> {
4966        Quantization::from_gguf_filename(name)
4967    }
4968
4969    #[test]
4970    fn reads_the_quantization_a_gguf_file_names() {
4971        let cases = [
4972            ("Qwen3-8B-Q4_K_M.gguf", "Q4_K_M", QuantScheme::KQuantMixed),
4973            (
4974                "Qwen3-Embedding-0.6B-Q8_0.gguf",
4975                "Q8_0",
4976                QuantScheme::RtnBlock,
4977            ),
4978            (
4979                "ggml-large-v3-turbo-q5_0.gguf",
4980                "q5_0",
4981                QuantScheme::RtnBlock,
4982            ),
4983            ("model-IQ4_XS.gguf", "IQ4_XS", QuantScheme::KQuantMixed),
4984        ];
4985        for (filename, label, scheme) in cases {
4986            let q = quantization_from_gguf_filename(filename)
4987                .unwrap_or_else(|| panic!("no quantization found in {filename}"));
4988            assert_eq!(q.label, label, "label for {filename}");
4989            assert_eq!(q.scheme, scheme, "scheme for {filename}");
4990        }
4991    }
4992
4993    /// A name with nothing quant-shaped in it must yield nothing, not a guess.
4994    #[test]
4995    fn returns_none_when_the_name_says_nothing() {
4996        for filename in ["model.gguf", "llama-2-7b-chat.gguf", "ggml-base.gguf"] {
4997            assert!(
4998                quantization_from_gguf_filename(filename).is_none(),
4999                "should not have guessed from {filename}"
5000            );
5001        }
5002    }
5003
5004    /// Scanning from the right means a quant-shaped word earlier in the model
5005    /// name cannot outrank the real suffix.
5006    #[test]
5007    fn the_rightmost_match_wins() {
5008        let q = quantization_from_gguf_filename("q8-experiment-Q4_K_M.gguf").unwrap();
5009        assert_eq!(q.label, "Q4_K_M");
5010    }
5011}
5012
5013#[cfg(test)]
5014mod local_availability_tests {
5015    use super::*;
5016    use crate::schema::ModelSchema;
5017    use tempfile::TempDir;
5018
5019    fn gguf_row(id: &str, hf_repo: &str) -> ModelSchema {
5020        let mut schema: ModelSchema = serde_json::from_value(serde_json::json!({
5021            "id": id,
5022            "name": id.replace('/', "-"),
5023            "provider": "qwen",
5024            "family": "qwen3",
5025            "capabilities": ["generate"],
5026            "context_length": 32768,
5027            "param_count": "8B",
5028            "source": {
5029                "type": "local",
5030                "hf_repo": hf_repo,
5031                "hf_filename": "model.gguf",
5032                "tokenizer_repo": hf_repo,
5033            },
5034            "cost": { "size_mb": 4900 },
5035        }))
5036        .unwrap();
5037        schema.available = false;
5038        schema
5039    }
5040
5041    /// GGUF is the local path on every machine that is not Apple-Silicon-MLX —
5042    /// i.e. the CUDA machines. `ensure_local` downloads `model.gguf` and
5043    /// `tokenizer.json` from the declared repo on first use, exactly as the MLX
5044    /// path does, so a declared repo is functionally available.
5045    ///
5046    /// This branch was left out of #164, which gave the MLX path that rule, and
5047    /// out of whisper.cpp's unconditional `true`. The result was a fresh CUDA
5048    /// install reporting every local model unavailable, the router dropping
5049    /// them from fallback chains, and the user routed to cloud with an idle GPU
5050    /// one download away.
5051    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
5052    #[test]
5053    fn a_declared_repo_is_available_before_it_is_downloaded() {
5054        let _environment = crate::openrouter::test_environment_scope();
5055        let tmp = TempDir::new().unwrap();
5056        let models = tmp.path().join("models");
5057        std::fs::create_dir_all(&models).unwrap();
5058
5059        let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
5060        reg.register(gguf_row("qwen/test-8b:q4_k_m", "Qwen/Qwen3-8B-GGUF"));
5061
5062        let model = reg.get("qwen/test-8b:q4_k_m").expect("registered");
5063        assert!(
5064            model.available,
5065            "a GGUF model with a repo to fetch from must not report unavailable \
5066             just because nothing has downloaded it yet"
5067        );
5068    }
5069
5070    /// The exception, and the reason the rule is `!hf_repo.is_empty()` rather
5071    /// than blanket `true`: rows from the local-directory scan have nowhere to
5072    /// fetch from, so they stay gated on the file being physically present.
5073    #[test]
5074    fn a_row_with_nowhere_to_fetch_from_stays_unavailable() {
5075        let _environment = crate::openrouter::test_environment_scope();
5076        let tmp = TempDir::new().unwrap();
5077        let models = tmp.path().join("models");
5078        std::fs::create_dir_all(&models).unwrap();
5079
5080        let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
5081        reg.register(gguf_row("local/scanned", ""));
5082
5083        let model = reg.get("local/scanned").expect("registered");
5084        assert!(
5085            !model.available,
5086            "an empty hf_repo has no download to promise"
5087        );
5088    }
5089}