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