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::{debug, 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    /// Credential variables whose state the LAST authoritative refresh could
154    /// not determine -- the secret store errored rather than answering
155    /// "absent". Per-refresh runtime state, deliberately here rather than on
156    /// `ModelSchema`, which is catalog content. Read when projecting a row's
157    /// `credential_required`: a store failure is not evidence a key is
158    /// missing, and rendering "Add key…" for one the person may already hold
159    /// is worse than saying nothing (car#1920).
160    credential_state_unknown: HashSet<String>,
161    /// Exact ids owned by the compiled or authenticated project catalog.
162    /// Public/user-controlled rows are additive and may never replace these.
163    project_model_ids: HashSet<String>,
164    /// The signed catalog each signed row came from, so a row kept after its
165    /// catalog moved on can be re-verified from those exact bytes at the
166    /// next startup.
167    signed_origin: HashMap<String, std::sync::Arc<crate::catalog::CatalogCacheEnvelope>>,
168    /// Signed rows a newer catalog withdrew but something still depends on;
169    /// persisted by the engine so a restart keeps them too.
170    retained_ids: HashSet<String>,
171    /// Ids the current signed catalog revokes. Kept so startup never restores
172    /// a retained row the publisher has since revoked.
173    revoked_ids: HashSet<String>,
174    /// Compiled builtins a revocation marked deprecated; the build's own value
175    /// comes back when a later catalog stops revoking them.
176    revoked_builtin_ids: HashSet<String>,
177    /// Compiled builtin ids whose metadata a signed row currently overlays
178    /// (see [`Self::overlay_builtin`]); the next catalog that omits one
179    /// restores the compiled values.
180    signed_overlay_ids: HashSet<String>,
181    /// Exact ids whose current rows came from the signed catalog, so the next
182    /// catalog replaces them rather than accreting: a row the publisher
183    /// dropped must leave the registry, not linger until restart.
184    signed_catalog_ids: HashSet<String>,
185    /// The strongest reserved namespace: exact ids compiled into this build.
186    /// Even an authenticated refresh is additive and cannot change them.
187    builtin_model_ids: HashSet<String>,
188    /// IDs whose current rows came from the user-controlled `models.json`
189    /// boundary or an explicit user registration.
190    ///
191    /// Tags cannot carry this provenance: a user can add `builtin`, while a
192    /// valid signed catalog row does not have to. Keeping the boundary private
193    /// prevents generic/builtin/catalog/discovery registration from being
194    /// serialized back into user config and losing its trust tier on restart.
195    user_config_ids: HashSet<String>,
196    /// IDs synthesized solely from directories found under `models_dir`.
197    ///
198    /// These rows exist only while their directories do. Keep provenance in a
199    /// private set rather than trusting the public `auto-discovered` tag: a
200    /// user-configured schema may carry any tag and must not disappear merely
201    /// because no same-named local directory exists.
202    on_disk_discovered_ids: HashSet<String>,
203    /// IDs whose current rows came from the unsigned provider discovery
204    /// cache, so a signed revocation can take them out again.
205    provider_discovered_ids: HashSet<String>,
206    /// User-added model config file path — `models.json` resolved against
207    /// `state_root`, i.e. the same answer the free [`user_config_path`] gives.
208    user_config_path: PathBuf,
209    /// Where to report downloads that nobody explicitly asked for.
210    ///
211    /// [`ensure_local`](Self::ensure_local) is the *implicit* acquisition path:
212    /// it runs inside `generate`, when the router picks a model whose weights
213    /// aren't on disk yet. It used to hard-code `ProgressSink::none()`, so a
214    /// multi-gigabyte fetch triggered by a plain `car infer "hi"` produced no
215    /// output on any surface — the command simply appeared to hang, and a
216    /// user on a metered connection had no signal at all (Parslee-ai/car#620).
217    ///
218    /// Explicit pulls (`car models pull`) always passed a real sink and were
219    /// never affected; this closes the gap for the path users actually hit
220    /// first. Defaults to [`ProgressSink::none`], so an embedder that doesn't
221    /// opt in behaves exactly as before.
222    ambient_progress: ProgressSink,
223    /// How this registry learns whether a Parslee session is signed in, and
224    /// whether it may act on a "no". See [`SessionProbe`]: normal production
225    /// registries carry `Live`; read-only diagnostics and test registries use
226    /// `Inert` so construction cannot erase an existing observation.
227    session: SessionProbe,
228}
229
230#[derive(Debug, Clone, Deserialize)]
231struct ModelUpgradeRule {
232    from_ids: Vec<String>,
233    to_id: String,
234    reason: String,
235    target_runtime: Option<String>,
236    target_runtime_requirement: Option<String>,
237    #[serde(default)]
238    minimum_runtimes: Vec<ModelRuntimeRequirement>,
239    #[serde(default = "default_remove_old_after_available")]
240    remove_old_after_available: bool,
241}
242
243fn default_remove_old_after_available() -> bool {
244    true
245}
246
247fn environment_credential_available(env_var: &str) -> bool {
248    std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
249}
250
251/// Does a credential exist, in the environment or the OS secret store —
252/// **without decrypting it**.
253///
254/// The distinction is enforced, not stylistic. `car-cli`'s
255/// `models_list_explicit_status_refreshes_openrouter_without_value_read` and
256/// its `doctor` twin assert that a status surface raises
257/// `secret_store_activity().status_attempts` and leaves `get_attempts`
258/// unchanged: answering "can I use this model?" is an existence question, and
259/// a surface that answers it by reading secret values hands every catalog
260/// render a reason to decrypt every provider key on the machine.
261///
262/// `resolve_env_or_keychain` is the wrong tool here for exactly that reason —
263/// it returns the value, and using it made this refresh perform four value
264/// reads per render. `status_via_operator_broker_or_keychain` takes the same
265/// broker decision and reduces the answer to a boolean before any caller sees
266/// it.
267///
268/// An error — a refused read, a store that will not open — is **not**
269/// evidence of absence, but it is also not evidence the model is usable, so it
270/// reports `false` the same way a missing key does. That is the conservative
271/// direction for a runnability claim: it can under-promise, never over-promise.
272pub(crate) fn credential_present_without_reading_it(env_var: &str) -> bool {
273    matches!(credential_presence(env_var), CredentialPresence::Present)
274}
275
276/// What the store could tell us about a credential, without decrypting it.
277///
278/// The third answer is the point. Collapsing a store FAILURE into "absent"
279/// made a temporarily unreadable key indistinguishable from one that was
280/// never set, and the model screen then told the person to add a credential
281/// they may already have (car#1920).
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub(crate) enum CredentialPresence {
284    Present,
285    Absent,
286    /// The store errored. Not evidence either way.
287    Unknown,
288}
289
290pub(crate) fn credential_presence(env_var: &str) -> CredentialPresence {
291    if environment_credential_available(env_var) {
292        return CredentialPresence::Present;
293    }
294    match car_secrets::status_via_operator_broker_or_keychain(&car_secrets::SecretRef::new(
295        car_secrets::DEFAULT_SERVICE,
296        env_var,
297    )) {
298        Ok(status) if status.exists => CredentialPresence::Present,
299        Ok(_) => CredentialPresence::Absent,
300        Err(_) => CredentialPresence::Unknown,
301    }
302}
303
304/// Process-local hint that an openai-codex sign-in exists. Refreshed by
305/// explicit status reads and by each openai-codex request credential lookup,
306/// so passive catalog reads never touch the secret store. Relaxed ordering is
307/// enough: it is an advisory availability hint, not a synchronisation point.
308static CODEX_SIGNED_IN_HINT: std::sync::atomic::AtomicBool =
309    std::sync::atomic::AtomicBool::new(false);
310
311/// Record whether an openai-codex sign-in exists.
312///
313/// Public so the daemon's ChatGPT sign-in can record a sign-in or sign-out it
314/// just made; the `openai-codex/*` rows then follow on the next catalog read.
315pub fn remember_codex_sign_in(present: bool) {
316    CODEX_SIGNED_IN_HINT.store(present, std::sync::atomic::Ordering::Relaxed);
317}
318
319/// Whether an openai-codex sign-in is known from the process-local hint,
320/// without touching the secret store.
321pub fn passive_codex_oauth_available() -> bool {
322    CODEX_SIGNED_IN_HINT.load(std::sync::atomic::Ordering::Relaxed)
323}
324
325fn passive_parslee_oauth_available() -> bool {
326    matches!(
327        car_auth::credential_authority_hint().state,
328        car_auth::CredentialAuthorityState::Configured
329    )
330}
331
332/// `resolved` is the per-refresh credential memo. Passive catalog refreshes
333/// populate it from environment presence only; request-time refreshes may
334/// populate it authoritatively from the secret store. Keeping the source of
335/// truth outside this helper prevents a single-model registration from
336/// silently becoming a physical Keychain read.
337fn proprietary_auth_available(
338    model_id: &str,
339    schema_provider: &str,
340    source_provider: &str,
341    auth: &ProprietaryAuth,
342    parslee_oauth_available: bool,
343    resolved: &std::collections::HashMap<String, bool>,
344) -> bool {
345    // Authentication is necessary but NOT sufficient for a PROXIED namespace.
346    // The OAuth2Pkce arm below resolves to exactly "is a Parslee session signed
347    // in", which is true regardless of what the gateway can actually serve —
348    // so ten `parslee/openrouter/*` aliases advertised `available` while every
349    // one of them 503'd with `openrouter_not_configured`, costing a benchmark
350    // sweep that picked one on the strength of that claim (Parslee-ai/car#786).
351    //
352    // There is no discovery endpoint to consult instead (`parslee.capabilities`
353    // enumerates product entitlements, not inference upstreams), so the gateway's
354    // own answer to a real request is the only truthful signal there is. Once it
355    // has told us, stop advertising — the catalog is then optimistic once and
356    // self-correcting, rather than permanently wrong.
357    //
358    // Checked here rather than at the call sites so `register` and
359    // `refresh_availability` cannot drift apart.
360    if crate::openrouter::is_curated_managed_gateway_alias(model_id)
361        && crate::openrouter::gateway_unconfigured()
362    {
363        return false;
364    }
365    // The neighbouring half of the same mistake, one layer earlier: a signed-in
366    // session is not a WORKING one. `access_token_is_available` is documented
367    // existence-only — it reports that a V2 record has an active slot, never
368    // that the token is still accepted — so a machine with a stale sign-in
369    // offered every managed alias as a top-quality candidate and 401'd on every
370    // one, per call, forever (Parslee-ai/car#887).
371    //
372    // Unlike the gateway check above this is NOT scoped to the curated
373    // OpenRouter aliases: a dead credential kills every `parslee/*` row, so the
374    // suppression belongs on the OAuth2Pkce arm as a whole.
375    //
376    // Checked here rather than at the call sites for the same reason as the
377    // gateway verdict — so `register` and `refresh_availability` cannot drift.
378    if crate::parslee_credential::credential_rejected()
379        && matches!(auth, ProprietaryAuth::OAuth2Pkce { .. })
380    {
381        return false;
382    }
383    match auth {
384        ProprietaryAuth::ApiKeyEnv { env_var } | ProprietaryAuth::BearerTokenEnv { env_var } => {
385            resolved.get(env_var).copied().unwrap_or(false)
386        }
387        ProprietaryAuth::OAuth2Pkce { .. } => {
388            schema_provider.eq_ignore_ascii_case("parslee")
389                && source_provider.eq_ignore_ascii_case("parslee")
390                && parslee_oauth_available
391        }
392        ProprietaryAuth::ChatGptSubscription {} => {
393            schema_provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
394                && source_provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER)
395                && passive_codex_oauth_available()
396        }
397    }
398}
399
400fn model_upgrade_rules() -> Vec<ModelUpgradeRule> {
401    serde_json::from_str(include_str!("../assets/model-upgrades.json"))
402        .expect("built-in model-upgrades.json should parse")
403}
404
405/// File name of the user-registered model config under the CAR state root.
406pub const USER_MODELS_FILE: &str = "models.json";
407
408/// The one resolver for `models.json`: [`USER_MODELS_FILE`] under the CAR state
409/// root (`~/.car/models.json` unless `CAR_HOME` moves the root).
410///
411/// Every reader and writer in the tree goes through this — the registry's own
412/// load/save, and the daemon's `models.register` / `models.unregister`
413/// handlers. They have to agree: when the write side moved to `CAR_HOME` and
414/// the read side stayed derived from the (deliberately unmoved) weights dir, a
415/// relocated daemon persisted a registration to one file and read a different
416/// one on the next boot, so the registration silently never took effect.
417///
418/// `None` only when there is no `CAR_HOME`, `HOME` or `USERPROFILE` — the same
419/// condition every other state path treats as "cannot resolve". The registry
420/// itself joins [`USER_MODELS_FILE`] onto the state root it was constructed
421/// with, which for [`UnifiedRegistry::new`] is the infallible
422/// `car_home::root_or_relative()`; same root, same file name, differing only in
423/// what they do when nothing resolves at all (error here, relative `.car`
424/// there — the pre-existing behaviour of each caller).
425pub fn user_config_path() -> Option<PathBuf> {
426    car_home::root().map(|root| root.join(USER_MODELS_FILE))
427}
428
429impl UnifiedRegistry {
430    /// Registry for the install that is actually running: state files resolve
431    /// under the CAR state root (`$CAR_HOME`, else `~/.car`), weights under
432    /// `models_dir`.
433    pub fn new(models_dir: PathBuf) -> Self {
434        Self::new_with_state_root(car_home::root_or_relative(), models_dir)
435    }
436
437    /// [`new`](Self::new) with the state root supplied rather than resolved
438    /// from the environment. The daemon passes its own resolved root; tests and
439    /// embedders that want every file inside one directory pass that directory.
440    pub fn new_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
441        let catalog_public_key = crate::catalog::catalog_public_key();
442        Self::new_with_catalog_public_key(state_root, models_dir, Some(&catalog_public_key))
443    }
444
445    fn new_with_catalog_public_key(
446        state_root: PathBuf,
447        models_dir: PathBuf,
448        catalog_public_key: Option<&str>,
449    ) -> Self {
450        // Production asks `car_auth` live and acts on the answer. Under
451        // `cfg(test)` the same question is asked the same way but held INERT, so
452        // building a registry — which every registry test does, and most of them
453        // without taking the crate's serial lock — reports identical
454        // availability while clearing nothing outside itself
455        // (Parslee-ai/car#986). A test that means to exercise the clear asks for
456        // it explicitly with `new_with_session(.., SessionProbe::Fixed(false))`.
457        #[cfg(not(test))]
458        let session = SessionProbe::Live;
459        #[cfg(test)]
460        let session = SessionProbe::Inert;
461        Self::new_with_session(state_root, models_dir, catalog_public_key, session)
462    }
463
464    /// [`new_with_catalog_public_key`](Self::new_with_catalog_public_key) with
465    /// the session answer supplied rather than defaulted. See [`SessionProbe`].
466    pub(crate) fn new_with_session(
467        state_root: PathBuf,
468        models_dir: PathBuf,
469        catalog_public_key: Option<&str>,
470        session: SessionProbe,
471    ) -> Self {
472        let user_config_path = state_root.join(USER_MODELS_FILE);
473
474        let mut registry = Self {
475            models_dir,
476            state_root,
477            models: HashMap::new(),
478            credential_state_unknown: HashSet::new(),
479            project_model_ids: HashSet::new(),
480            signed_catalog_ids: HashSet::new(),
481            signed_overlay_ids: HashSet::new(),
482            signed_origin: HashMap::new(),
483            retained_ids: HashSet::new(),
484            revoked_ids: HashSet::new(),
485            revoked_builtin_ids: HashSet::new(),
486            builtin_model_ids: HashSet::new(),
487            user_config_ids: HashSet::new(),
488            on_disk_discovered_ids: HashSet::new(),
489            provider_discovered_ids: HashSet::new(),
490            user_config_path,
491            ambient_progress: ProgressSink::none(),
492            session,
493        };
494        registry.load_builtin_catalog();
495        // Refreshable signed catalog (E1): a prior `refresh_catalog` stores the
496        // exact authenticated body + signature envelope. Startup re-verifies
497        // that envelope with the currently configured key before loading any
498        // model additively alongside the built-ins.
499        // Then rows an earlier catalog had that something here still depends
500        // on, each re-verified from the catalog it came from.
501        let cached = crate::catalog::load_verified(
502            &crate::catalog::cache_path(&registry.state_root),
503            catalog_public_key,
504        );
505        if let Some(cached) = cached {
506            let origin = std::sync::Arc::new(cached.envelope());
507            let revoked: HashSet<String> = cached.revoked().iter().cloned().collect();
508            registry.replace_signed_catalog(
509                cached.into_models(),
510                Some(origin),
511                &HashSet::new(),
512                &revoked,
513            );
514        }
515        registry.restore_retained(crate::catalog::load_retained(
516            &crate::catalog::retained_path(&registry.state_root),
517            catalog_public_key,
518        ));
519        // Auto-discovered models (E2): Community-tier entries cached by a prior
520        // discovery pass (provider /v1/models). Loaded on top of built-ins +
521        // signed catalog, but never *overwriting* a curated/signed entry of the
522        // same id — discovery only ever ADDS models the catalog doesn't have.
523        // Nor an id the signed catalog revokes: the unsigned cache must not
524        // undo a signed revocation. The row stays in the file, so a later
525        // catalog that lifts the revocation brings it back.
526        for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
527            &registry.state_models_dir(),
528        )) {
529            if !registry.models.contains_key(&schema.id) && !registry.is_revoked(&schema.id) {
530                registry.provider_discovered_ids.insert(schema.id.clone());
531                registry.register(schema);
532            }
533        }
534        registry.refresh_availability();
535        // Load user config on top (silently ignore if missing)
536        let _ = registry.load_user_config();
537        // Surface models the user pulled/placed under ~/.car/models/ that no
538        // catalog or user-config entry covers, so a locally-present model is
539        // visible and routable instead of invisible (car-releases#62).
540        registry.discover_on_disk_models();
541        registry
542    }
543
544    fn empty_with_state_root(state_root: PathBuf, models_dir: PathBuf) -> Self {
545        let user_config_path = state_root.join(USER_MODELS_FILE);
546        Self {
547            models_dir,
548            state_root,
549            models: HashMap::new(),
550            credential_state_unknown: HashSet::new(),
551            project_model_ids: HashSet::new(),
552            signed_catalog_ids: HashSet::new(),
553            signed_overlay_ids: HashSet::new(),
554            signed_origin: HashMap::new(),
555            retained_ids: HashSet::new(),
556            revoked_ids: HashSet::new(),
557            revoked_builtin_ids: HashSet::new(),
558            builtin_model_ids: HashSet::new(),
559            user_config_ids: HashSet::new(),
560            on_disk_discovered_ids: HashSet::new(),
561            provider_discovered_ids: HashSet::new(),
562            user_config_path,
563            ambient_progress: ProgressSink::none(),
564            // No refresh runs during construction, so this probe is never
565            // consulted. Keep it inert in case an internal caller refreshes.
566            session: SessionProbe::Inert,
567        }
568    }
569
570    /// Test-only: a registry with NO builtin catalog, signed cache, discovery
571    /// cache, or user config — only the models a test explicitly `register`s.
572    /// Routing unit tests that assert a synthetic model wins MUST use this:
573    /// `new()` loads the real builtin frontier models, which become routable
574    /// candidates the moment a test sets their `api_key_env` (e.g.
575    /// `OPENAI_API_KEY`) — and once those models carry real benchmark scores
576    /// (e.g. car-judged), they legitimately outrank the synthetic stand-ins and
577    /// silently break the test's assumption. An empty registry keeps the test
578    /// hermetic.
579    ///
580    /// The state root is taken to be `models_dir`'s parent, so a test that
581    /// passes `<tmp>/models` gets `<tmp>` and every state file stays inside the
582    /// temp dir. This constructor never reads or writes anything on its own, so
583    /// the derivation only matters if the test then calls `save_user_config`.
584    #[cfg(test)]
585    pub fn new_empty(models_dir: PathBuf) -> Self {
586        let state_root = models_dir.parent().unwrap_or(&models_dir).to_path_buf();
587        Self::empty_with_state_root(state_root, models_dir)
588    }
589
590    /// Registry for a deterministic offline diagnosis fixture.
591    ///
592    /// It reads only the explicitly named models directory: no builtin,
593    /// signed, discovered, or user catalog is loaded, and no credential or
594    /// session availability probe runs. Local model directories are inferred
595    /// with the same conservative on-disk discovery used by production.
596    pub(crate) fn new_isolated_for_diagnosis(state_root: PathBuf, models_dir: PathBuf) -> Self {
597        let mut registry = Self::empty_with_state_root(state_root, models_dir);
598        registry.discover_on_disk_models();
599        registry
600    }
601
602    /// Where the registry's own state that has always sat beside the weights
603    /// lives: `models/` under the state root. Identical to `models_dir`
604    /// whenever `CAR_HOME` is unset, which is why no existing install's files
605    /// move; different — and per-daemon — once it is set.
606    fn state_models_dir(&self) -> PathBuf {
607        self.state_root.join("models")
608    }
609
610    /// Register on-disk models under `models_dir` that nothing else already
611    /// covers. Curated catalog entries already light up via the
612    /// availability check (their `name` maps to `<models_dir>/<name>`), so
613    /// this only fills the gap for *uncatalogued* local models — e.g. a
614    /// model a user fetched by hand.
615    ///
616    /// Capability guessing from a bare directory is unreliable, and
617    /// mis-tagging a speech/vision/video checkpoint as `Generate` would
618    /// poison routing. So this is deliberately conservative: it only
619    /// registers text LLMs it can positively identify by a recognized
620    /// `model_type` in `config.json` (MLX) or by a GGUF weight file, infers
621    /// embed/rerank from the directory name, and skips everything else.
622    /// car-releases#62.
623    fn discover_on_disk_models(&mut self) {
624        let entries = match std::fs::read_dir(&self.models_dir) {
625            Ok(e) => e,
626            Err(_) => return,
627        };
628        // Names already registered (case-insensitive) — never shadow a
629        // curated/user/discovered entry with an inferred one — and names of
630        // models a signed catalog revoked, which must not come back as
631        // uncatalogued local ones.
632        let known: std::collections::HashSet<String> = self
633            .models
634            .values()
635            .map(|m| m.name.to_ascii_lowercase())
636            .chain(
637                crate::catalog::load_revoked_names(&crate::catalog::revoked_names_path(
638                    &self.state_root,
639                ))
640                .into_keys()
641                .map(|name| name.to_ascii_lowercase()),
642            )
643            .collect();
644
645        for entry in entries.flatten() {
646            let path = entry.path();
647            if !path.is_dir() {
648                continue;
649            }
650            let Some(name) = path
651                .file_name()
652                .and_then(|n| n.to_str())
653                .map(str::to_string)
654            else {
655                continue;
656            };
657            if known.contains(&name.to_ascii_lowercase()) {
658                continue;
659            }
660
661            let Some(schema) = synthesize_local_schema(&name, &path) else {
662                continue;
663            };
664            // The name check above cannot see an id collision: a row
665            // registered under another name may own the id this directory
666            // normalizes to. Leave that row alone — replacing it would discard
667            // its definition, and tagging it would let deleting this directory
668            // erase it (car#1650).
669            if self.models.contains_key(&schema.id) {
670                tracing::debug!(
671                    id = %schema.id,
672                    name = %name,
673                    "skipping local model directory whose id is already registered (car#1650)"
674                );
675                continue;
676            }
677            tracing::info!(
678                id = %schema.id,
679                name = %name,
680                "auto-discovered uncatalogued local model under models_dir (car-releases#62)"
681            );
682            let id = schema.id.clone();
683            self.register(schema);
684            if self.models.contains_key(&id) {
685                self.on_disk_discovered_ids.insert(id);
686            }
687        }
688    }
689
690    /// Remove inferred rows whose source directories no longer exist.
691    ///
692    /// The daemon keeps its registry in memory, so an on-disk model removed by
693    /// another process otherwise remains visible until restart. Activity and
694    /// mutation lock records are durable coordination sentinels, not registry
695    /// entries, and deliberately have no bearing on this reconciliation.
696    pub(crate) fn prune_missing_on_disk_models(&mut self) {
697        let missing = self
698            .on_disk_discovered_ids
699            .iter()
700            .filter(|id| {
701                self.models
702                    .get(id.as_str())
703                    .is_none_or(|schema| !self.models_dir.join(&schema.name).is_dir())
704            })
705            .cloned()
706            .collect::<Vec<_>>();
707        for id in missing {
708            tracing::debug!(
709                model_id = %id,
710                "dropping vanished auto-discovered model; coordination records do not register models"
711            );
712            self.on_disk_discovered_ids.remove(&id);
713            self.models.remove(&id);
714        }
715    }
716
717    /// Register a model at a public runtime boundary.
718    ///
719    /// Callers cannot confer project curation: even a legacy schema whose
720    /// omitted `trust_tier` deserializes as `Curated` is normalized to
721    /// `Community` here. Project-owned builtins and signature-verified catalogs
722    /// use the crate-private [`register_project_model`](Self::register_project_model)
723    /// boundary instead.
724    pub fn register(&mut self, mut schema: ModelSchema) {
725        if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
726            warn!(id = %schema.id, "ignoring user registration for reserved Parslee-managed alias");
727            return;
728        }
729        if self.project_model_ids.contains(&schema.id) {
730            warn!(id = %schema.id, "ignoring public registration for project-owned exact id");
731            return;
732        }
733        schema.mark_user_registered();
734        let id = schema.id.clone();
735        if self.register_preserving_trust(schema) {
736            self.on_disk_discovered_ids.remove(&id);
737        }
738    }
739
740    /// Register a model whose provenance was established by CAR itself.
741    ///
742    /// This trust-preserving path is intentionally crate-private. Production
743    /// callers are limited to compiled builtins and signature-verified catalog
744    /// rows; tests may use it to construct project-curated fixtures.
745    pub(crate) fn register_project_model(&mut self, schema: ModelSchema) -> bool {
746        let id = schema.id.clone();
747        if !self.register_preserving_trust(schema) {
748            return false;
749        }
750        self.on_disk_discovered_ids.remove(&id);
751        self.project_model_ids.insert(id);
752        true
753    }
754
755    /// Make `rows` the signed catalog, under the one rule both startup and a
756    /// live refresh use. Returns how many rows it loaded or overlaid.
757    ///
758    /// - A row for a compiled builtin id overlays only the builtin's
759    ///   [`OVERLAY_FIELDS`](Self::overlay_builtin) — benchmarks, cost,
760    ///   performance, deprecation — and only when it names the same weights;
761    ///   which weights an id loads stays fixed by the build.
762    /// - Any other row is additive: never over another project-owned id, and
763    ///   never over a row the user registered, whose definition wins while
764    ///   the daemon runs and is what `save_user_config` keeps writing.
765    /// - A previous signed row the new catalog drops leaves — unless `keep`
766    ///   names it (installed, pinned, a lane default). Then it stays
767    ///   resolvable, marked `deprecated` so nothing suggests it, until nothing
768    ///   depends on it. Removing it would orphan the weights and blank the
769    ///   lane that names it.
770    pub(crate) fn replace_signed_catalog(
771        &mut self,
772        rows: Vec<ModelSchema>,
773        origin: Option<std::sync::Arc<crate::catalog::CatalogCacheEnvelope>>,
774        keep: &HashSet<String>,
775        revoked: &HashSet<String>,
776    ) -> usize {
777        let incoming: HashSet<&str> = rows.iter().map(|m| m.id.as_str()).collect();
778        for id in std::mem::take(&mut self.signed_catalog_ids) {
779            // A revoked row goes whatever depends on it: revocation is the
780            // publisher saying the weights are bad, not merely superseded.
781            if keep.contains(&id) && !incoming.contains(id.as_str()) && !revoked.contains(&id) {
782                if let Some(row) = self.models.get_mut(&id) {
783                    row.deprecated = true;
784                }
785                self.retained_ids.insert(id.clone());
786                self.signed_catalog_ids.insert(id);
787                continue;
788            }
789            self.models.remove(&id);
790            self.project_model_ids.remove(&id);
791            self.credential_state_unknown.remove(&id);
792            self.signed_origin.remove(&id);
793            self.retained_ids.remove(&id);
794        }
795        let overlaid = std::mem::take(&mut self.signed_overlay_ids);
796        let unrevoked = std::mem::take(&mut self.revoked_builtin_ids);
797        let restore: HashSet<String> = overlaid.union(&unrevoked).cloned().collect();
798        let compiled: HashMap<String, ModelSchema> = if restore.is_empty() {
799            HashMap::new()
800        } else {
801            builtin_catalog()
802                .into_iter()
803                .filter(|m| restore.contains(&m.id))
804                .map(|m| (m.id.clone(), m))
805                .collect()
806        };
807        for id in &restore {
808            if let (Some(row), Some(original)) = (self.models.get_mut(id), compiled.get(id)) {
809                Self::copy_overlay_fields(original, row);
810            }
811        }
812        let lifted: HashSet<String> = std::mem::replace(&mut self.revoked_ids, revoked.clone())
813            .difference(revoked)
814            .cloned()
815            .collect();
816        // A provider-discovered row is unsigned; a revocation of its id wins.
817        // A row the user registered under that id is theirs and stays.
818        for id in revoked {
819            if self.provider_discovered_ids.remove(id) && !self.user_config_ids.contains(id) {
820                self.models.remove(id);
821            }
822        }
823        let mut loaded = 0;
824        for schema in rows {
825            let id = schema.id.clone();
826            if revoked.contains(&id) {
827                warn!(%id, "ignoring a signed catalog row its own catalog revokes");
828                continue;
829            }
830            if self.register_signed_catalog_model(schema) {
831                loaded += 1;
832                if let Some(origin) = &origin {
833                    if self.signed_catalog_ids.contains(&id) {
834                        self.signed_origin.insert(id, origin.clone());
835                    }
836                }
837            }
838        }
839        // A lifted revocation brings back the discovery cache's row for that
840        // id, after this catalog's own rows and never over any present row.
841        if !lifted.is_empty() {
842            for schema in crate::discovery::load_cache(&crate::discovery::cache_path(
843                &self.state_models_dir(),
844            )) {
845                if lifted.contains(&schema.id) && !self.models.contains_key(&schema.id) {
846                    self.provider_discovered_ids.insert(schema.id.clone());
847                    self.register(schema);
848                }
849            }
850        }
851        // A revoked builtin cannot leave the build; it stops being suggested
852        // or applied.
853        for id in revoked {
854            if self.builtin_model_ids.contains(id) {
855                if let Some(row) = self.models.get_mut(id) {
856                    row.deprecated = true;
857                    self.revoked_builtin_ids.insert(id.clone());
858                }
859            }
860        }
861        loaded
862    }
863
864    /// Whether the current signed catalog revokes `id`.
865    pub fn is_revoked(&self, id: &str) -> bool {
866        self.revoked_ids.contains(id)
867    }
868
869    /// Ids the current signed catalog revokes.
870    pub fn revoked_ids(&self) -> Vec<String> {
871        self.revoked_ids.iter().cloned().collect()
872    }
873
874    /// Whether `id` is a row compiled into this build (signed overlays
875    /// included), as opposed to one only a signed catalog or the user added.
876    pub fn is_builtin(&self, id: &str) -> bool {
877        self.builtin_model_ids.contains(id)
878    }
879
880    /// Signed rows kept after their catalog withdrew them, with the catalog
881    /// each came from — what the engine persists.
882    pub(crate) fn retained_rows(
883        &self,
884    ) -> Vec<(String, std::sync::Arc<crate::catalog::CatalogCacheEnvelope>)> {
885        let mut rows: Vec<_> = self
886            .retained_ids
887            .iter()
888            .filter_map(|id| Some((id.clone(), self.signed_origin.get(id)?.clone())))
889            .collect();
890        rows.sort_by(|a, b| a.0.cmp(&b.0));
891        rows
892    }
893
894    /// The signed catalog a row came from, if it is a signed row.
895    pub(crate) fn signed_origin_of(
896        &self,
897        id: &str,
898    ) -> Option<std::sync::Arc<crate::catalog::CatalogCacheEnvelope>> {
899        self.signed_origin.get(id).cloned()
900    }
901
902    /// Bring back verified rows as retained — deprecated, resolvable, never
903    /// suggested — unless the id is already present. Startup restores what
904    /// the last run kept; a pull that outlived its row's catalog keeps it.
905    pub(crate) fn restore_retained(
906        &mut self,
907        rows: Vec<(
908            ModelSchema,
909            std::sync::Arc<crate::catalog::CatalogCacheEnvelope>,
910        )>,
911    ) {
912        for (mut schema, origin) in rows {
913            if self.models.contains_key(&schema.id) || self.revoked_ids.contains(&schema.id) {
914                continue;
915            }
916            schema.deprecated = true;
917            let id = schema.id.clone();
918            if self.register_signed_catalog_model(schema) && self.signed_catalog_ids.contains(&id) {
919                self.signed_origin.insert(id.clone(), origin);
920                self.retained_ids.insert(id);
921            }
922        }
923    }
924
925    /// The fields a signed row may change on a compiled builtin: what CAR
926    /// measured or priced about a model, never which weights it loads.
927    fn copy_overlay_fields(from: &ModelSchema, to: &mut ModelSchema) {
928        to.public_benchmarks = from.public_benchmarks.clone();
929        to.cost = from.cost.clone();
930        to.performance = from.performance.clone();
931        to.deprecated = from.deprecated;
932    }
933
934    /// Apply a signed row's measurements to the compiled builtin it names.
935    /// Refused when the row names different weights: a publisher may update
936    /// what CAR knows about a builtin, not repoint it.
937    fn overlay_builtin(&mut self, schema: &ModelSchema) -> bool {
938        let Some(row) = self.models.get_mut(&schema.id) else {
939            return false;
940        };
941        let same_weights =
942            serde_json::to_value(&row.source).ok() == serde_json::to_value(&schema.source).ok();
943        if !same_weights {
944            warn!(id = %schema.id, "ignoring signed overlay that names different weights for a compiled builtin");
945            return false;
946        }
947        Self::copy_overlay_fields(schema, row);
948        self.signed_overlay_ids.insert(schema.id.clone());
949        true
950    }
951
952    /// Add one signature-verified catalog row without allowing the remote
953    /// publisher to redefine an exact id already owned by this build, a user
954    /// registration, or an earlier authenticated row in the same document.
955    fn register_signed_catalog_model(&mut self, schema: ModelSchema) -> bool {
956        if self.builtin_model_ids.contains(&schema.id) {
957            if self.signed_overlay_ids.contains(&schema.id) {
958                warn!(id = %schema.id, "ignoring duplicate signed overlay for compiled builtin");
959                return false;
960            }
961            return self.overlay_builtin(&schema);
962        }
963        if self.user_config_ids.contains(&schema.id) {
964            warn!(id = %schema.id, "ignoring signed catalog row for a user-registered id");
965            return false;
966        }
967        if self.project_model_ids.contains(&schema.id) {
968            warn!(id = %schema.id, "ignoring duplicate signed catalog row for project-owned exact id");
969            return false;
970        }
971        let id = schema.id.clone();
972        let registered = self.register_project_model(schema);
973        if registered {
974            self.signed_catalog_ids.insert(id);
975        }
976        registered
977    }
978
979    fn register_preserving_trust(&mut self, mut schema: ModelSchema) -> bool {
980        if let Err(error) = crate::catalog_identity::row_digest(&schema) {
981            warn!(id = %schema.id, %error, "rejecting model without canonical catalog identity");
982            return false;
983        }
984        // Check availability for local models
985        if schema.is_mlx() || schema.is_car_managed_vllm_mlx() {
986            // MLX requires Apple Silicon Metal. On any other build target —
987            // Intel Mac, Linux, Windows, or `car_skip_mlx` — the backend
988            // is not compiled in and any execution attempt would fail at
989            // dispatch with a "model not found" or backend-missing error.
990            // Mirror the AppleFoundationModels cfg-gating below: the
991            // registry must reflect that the model is *not* runnable here
992            // so the adaptive router doesn't add it to fallback chains
993            // (Parslee-ai/car#231 — §7.1, the "fresh Windows install has
994            // no usable inference path" finding). Same shape as the
995            // `refresh_availability` MLX branch.
996            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
997            {
998                schema.available = if schema.tags.contains(&"speech".to_string()) {
999                    speech_mlx_available()
1000                } else if let ModelSource::Mlx { ref hf_repo, .. }
1001                | ModelSource::ManagedVllmMlx { ref hf_repo, .. } = schema.source
1002                {
1003                    // A row the in-process loader runs needs the Swift MLX
1004                    // stack linked into THIS binary; the target cfg above does
1005                    // not imply it (`CAR_BUILD_SWIFT_LM=0`, or a Swift build
1006                    // that failed and warned). A row marked available without
1007                    // it is one the router puts in a fallback chain and then
1008                    // fails on. Everything else under `ModelSource::Mlx` is a
1009                    // subprocess and does not care — see
1010                    // `mlx_row_needs_in_process_loader`.
1011                    //
1012                    // Given its executor, available if cached locally OR it
1013                    // declares an hf_repo — ensure_local() lazy-downloads on
1014                    // first use, so that is "functionally available" the same
1015                    // way Ollama/RemoteApi entries are (#164).
1016                    let loader_ok = !mlx_row_needs_in_process_loader(&schema)
1017                        || crate::backend::local::in_process_loader_available();
1018                    let mlx_dir = self.models_dir.join(&schema.name);
1019                    loader_ok && (mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty())
1020                } else {
1021                    let mlx_dir = self.models_dir.join(&schema.name);
1022                    mlx_dir_has_weights(&mlx_dir)
1023                };
1024            }
1025            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1026            {
1027                schema.available = false;
1028            }
1029        } else if schema.is_vllm_mlx() {
1030            // vLLM-MLX: available if endpoint env var set or was manually marked available
1031            schema.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available;
1032        } else if matches!(schema.source, ModelSource::WhisperCpp { .. }) {
1033            // whisper.cpp compiles on every platform and lazy-downloads its ggml
1034            // model on first use, so it's functionally available everywhere
1035            // (mirrors refresh_availability). Checked before the is_local() GGUF
1036            // branch below — whisper is_local() too but has no model.gguf.
1037            schema.available = true;
1038        } else if matches!(schema.source, ModelSource::WindowsSpeech {}) {
1039            // OS-provided WinRT synthesizer — available on Windows only.
1040            schema.available = cfg!(target_os = "windows");
1041        } else if schema.is_codex_cli() {
1042            // The CLI owns auth; availability only answers whether there is a
1043            // binary to attempt. Login failures surface from the child without
1044            // CAR reading Codex's credential store.
1045            schema.available = crate::backend::codex_cli::is_available();
1046        } else if schema.is_local() {
1047            let local_path = self.models_dir.join(&schema.name).join("model.gguf");
1048            // Mirrors the `ModelSource::Local` arm in `refresh_availability`:
1049            // where the Candle GGUF backend exists, a declared `hf_repo` is
1050            // functionally available because `ensure_local` fetches from it.
1051            // Registration and refresh must agree, or a model's availability
1052            // flips depending on which ran last.
1053            let lazily_fetchable = matches!(
1054                schema.source,
1055                ModelSource::Local { ref hf_repo, .. } if !hf_repo.is_empty()
1056            ) && !cfg!(all(
1057                target_os = "macos",
1058                target_arch = "aarch64",
1059                not(car_skip_mlx)
1060            ));
1061            schema.available = local_path.exists() || lazily_fetchable;
1062        } else if schema.is_remote() {
1063            // Registration is catalog work, so it may use environment presence
1064            // and non-secret authority hints but must never query a secret
1065            // backend. Request-time snapshots refresh this authoritatively.
1066            schema.available = match schema.source {
1067                ModelSource::RemoteApi {
1068                    protocol: crate::schema::ApiProtocol::OpenRouter,
1069                    ..
1070                } => crate::openrouter::credential_source().is_some(),
1071                ModelSource::RemoteApi {
1072                    ref api_key_env, ..
1073                } => environment_credential_available(api_key_env),
1074                ModelSource::Proprietary {
1075                    ref provider,
1076                    ref auth,
1077                    ..
1078                } => {
1079                    let resolved = match auth {
1080                        ProprietaryAuth::ApiKeyEnv { env_var }
1081                        | ProprietaryAuth::BearerTokenEnv { env_var } => {
1082                            std::collections::HashMap::from([(
1083                                env_var.clone(),
1084                                environment_credential_available(env_var),
1085                            )])
1086                        }
1087                        ProprietaryAuth::OAuth2Pkce { .. }
1088                        | ProprietaryAuth::ChatGptSubscription {} => Default::default(),
1089                    };
1090                    proprietary_auth_available(
1091                        &schema.id,
1092                        &schema.provider,
1093                        provider,
1094                        auth,
1095                        self.session.available(),
1096                        &resolved,
1097                    )
1098                }
1099                _ => schema.available,
1100            };
1101        }
1102        // Ready = usable without a download. For anything local that means
1103        // weights resolvable on disk *now*; `available` alone can't answer this,
1104        // since a declared `hf_repo` makes an MLX model "available" before a
1105        // byte is fetched (#164). Remote models never need a weights download.
1106        // Their live credential state belongs in `available`; coupling it to
1107        // `weights_ready` permanently excludes models registered before a key
1108        // is connected from `require_ready` routes.
1109        // See `ModelSchema::weights_ready` (Parslee-ai/car#638).
1110        // NOTE: `is_mlx()` must be tested before `is_local()` — `is_local()`
1111        // returns true for `ModelSource::Mlx` too, so the GGUF branch would
1112        // otherwise swallow every MLX model and look for a `model.gguf` that
1113        // never exists.
1114        schema.weights_ready = physical_weights_ready(&schema, &self.models_dir);
1115        // `debug`, not `info`: this fires once per catalog entry, and the
1116        // catalog is ~87 rows. At the default filter that put 87 lines of
1117        // bookkeeping in front of whatever the person actually asked for —
1118        // `car do "What is 2 plus 2?"`, `car setup`, `car doctor`.
1119        //
1120        // car#1788 quieted it per-command, which needs a new entry in
1121        // `should_quiet_tracing_for_machine_output` for every command that
1122        // builds a registry; `do` and `setup` were missed and reported as
1123        // car#1873. Registering one known model is not an event worth an
1124        // `info` line, so lowering it fixes every command at once and cannot
1125        // be missed by a new one. `RUST_LOG=debug` still shows the full set.
1126        debug!(
1127            id = %schema.id,
1128            name = %schema.name,
1129            available = schema.available,
1130            weights_ready = schema.weights_ready,
1131            "registered model"
1132        );
1133        self.models.insert(schema.id.clone(), schema);
1134        true
1135    }
1136
1137    /// Register a schema that crossed the user-controlled configuration
1138    /// boundary.
1139    ///
1140    /// This is deliberately separate from [`register`](Self::register):
1141    /// builtin, signed-catalog, and discovery rows must never become
1142    /// `models.json` rows merely because they share the same in-memory map.
1143    pub fn register_user_model(&mut self, mut schema: ModelSchema) {
1144        if crate::openrouter::is_curated_managed_gateway_alias(&schema.id) {
1145            warn!(id = %schema.id, "ignoring persisted user model for reserved Parslee-managed alias");
1146            return;
1147        }
1148        if self.project_model_ids.contains(&schema.id) {
1149            warn!(id = %schema.id, "ignoring persisted user model for project-owned exact id");
1150            return;
1151        }
1152        schema.mark_user_registered();
1153        let id = schema.id.clone();
1154        if self.register_preserving_trust(schema) {
1155            self.on_disk_discovered_ids.remove(&id);
1156            self.provider_discovered_ids.remove(&id);
1157            self.user_config_ids.insert(id);
1158        }
1159    }
1160
1161    /// Unregister a model by id. Returns the removed schema if found.
1162    pub fn unregister(&mut self, id: &str) -> Option<ModelSchema> {
1163        self.on_disk_discovered_ids.remove(id);
1164        self.provider_discovered_ids.remove(id);
1165        let removed = self.models.remove(id);
1166        if let Some(ref m) = removed {
1167            info!(id = %m.id, "unregistered model");
1168        }
1169        removed
1170    }
1171
1172    /// Unregister an explicitly user-configured model.
1173    ///
1174    /// An untracked builtin, signed-catalog, or discovery row is not removable
1175    /// through this persistence boundary.
1176    pub fn unregister_user_model(&mut self, id: &str) -> Option<ModelSchema> {
1177        if !self.user_config_ids.remove(id) {
1178            return None;
1179        }
1180        self.unregister(id)
1181    }
1182
1183    /// List all models.
1184    pub fn list(&self) -> Vec<&ModelSchema> {
1185        let mut models: Vec<&ModelSchema> = self.models.values().collect();
1186        models.sort_by(|a, b| a.id.cmp(&b.id));
1187        models
1188    }
1189
1190    /// Query models matching a filter.
1191    pub fn query(&self, filter: &ModelFilter) -> Vec<&ModelSchema> {
1192        self.models
1193            .values()
1194            .filter(|m| {
1195                // Capability check: model must have ALL required capabilities
1196                if !filter.capabilities.iter().all(|c| m.has_capability(*c)) {
1197                    return false;
1198                }
1199                // Size check
1200                if let Some(max) = filter.max_size_mb {
1201                    if m.size_mb() > max && m.is_local() {
1202                        return false;
1203                    }
1204                }
1205                // Latency check (declared envelope)
1206                if let Some(max) = filter.max_latency_ms {
1207                    if let Some(p50) = m.performance.latency_p50_ms {
1208                        if p50 > max {
1209                            return false;
1210                        }
1211                    }
1212                }
1213                // Cost check
1214                if let Some(max) = filter.max_cost_per_mtok {
1215                    if let Some(cost) = m.cost.output_per_mtok {
1216                        if cost > max {
1217                            return false;
1218                        }
1219                    }
1220                }
1221                // Tag check
1222                if !filter.tags.iter().all(|t| m.tags.contains(t)) {
1223                    return false;
1224                }
1225                // Provider check
1226                if let Some(ref p) = filter.provider {
1227                    if &m.provider != p {
1228                        return false;
1229                    }
1230                }
1231                // Local only
1232                if filter.local_only && !m.is_local() {
1233                    return false;
1234                }
1235                // Available only
1236                if filter.available_only && !m.available_now() {
1237                    return false;
1238                }
1239                true
1240            })
1241            .collect()
1242    }
1243
1244    /// Query models by a single capability.
1245    pub fn query_by_capability(&self, cap: ModelCapability) -> Vec<&ModelSchema> {
1246        self.query(&ModelFilter {
1247            capabilities: vec![cap],
1248            ..Default::default()
1249        })
1250    }
1251
1252    /// Report installed local models with curated newer replacements.
1253    pub fn available_upgrades(&self) -> Vec<ModelUpgrade> {
1254        let mut upgrades = Vec::new();
1255        for rule in model_upgrade_rules() {
1256            let Some(from) = rule
1257                .from_ids
1258                .iter()
1259                .find_map(|id| self.models.get(id.as_str()))
1260                .filter(|schema| schema.available)
1261            else {
1262                continue;
1263            };
1264            let Some(to) = self.models.get(rule.to_id.as_str()) else {
1265                continue;
1266            };
1267            upgrades.push(ModelUpgrade {
1268                from_id: from.id.clone(),
1269                from_name: from.name.clone(),
1270                to_id: to.id.clone(),
1271                to_name: to.name.clone(),
1272                reason: rule.reason.clone(),
1273                target_runtime: rule.target_runtime.clone(),
1274                target_runtime_requirement: rule.target_runtime_requirement.clone(),
1275                minimum_runtimes: rule.minimum_runtimes.clone(),
1276                target_available: to.available,
1277                target_pullable: matches!(
1278                    to.source,
1279                    ModelSource::Local { .. } | ModelSource::Mlx { .. }
1280                ),
1281                remove_old_supported: matches!(
1282                    from.source,
1283                    ModelSource::Local { .. } | ModelSource::Mlx { .. }
1284                ) && rule.remove_old_after_available,
1285            });
1286        }
1287        upgrades.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
1288        upgrades.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
1289        upgrades
1290    }
1291
1292    /// Get a specific model by id.
1293    pub fn get(&self, id: &str) -> Option<&ModelSchema> {
1294        self.models.get(id)
1295    }
1296
1297    /// Return the currently registered schema without refreshing availability.
1298    ///
1299    /// Callers that need runtime credential/file state must explicitly use a
1300    /// refreshing snapshot; identity/provenance checks should use this exact
1301    /// stored row instead.
1302    pub fn registered_schema(&self, id: &str) -> Option<&ModelSchema> {
1303        self.get(id)
1304    }
1305
1306    /// Iterate all registered model schemas (built-in + signed + discovered).
1307    pub fn all(&self) -> impl Iterator<Item = &ModelSchema> {
1308        self.models.values()
1309    }
1310
1311    /// Find a model by name (case-insensitive). For backward compatibility
1312    /// with the old registry that used short names like "Qwen3-4B".
1313    pub fn find_by_name(&self, name: &str) -> Option<&ModelSchema> {
1314        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1315        if !name.to_ascii_lowercase().ends_with("-mlx") {
1316            if let Some(mlx_variant) = self
1317                .models
1318                .values()
1319                .find(|m| m.name.eq_ignore_ascii_case(&format!("{name}-MLX")))
1320            {
1321                return Some(mlx_variant);
1322            }
1323        }
1324
1325        self.models
1326            .values()
1327            .find(|m| m.name.eq_ignore_ascii_case(name))
1328    }
1329
1330    /// On Apple Silicon, resolve a GGUF/Candle model to its MLX equivalent.
1331    /// Returns the MLX model schema if one exists with the same family and
1332    /// matching capabilities; otherwise returns None.
1333    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1334    pub fn resolve_mlx_equivalent(&self, schema: &ModelSchema) -> Option<&ModelSchema> {
1335        // Already MLX — no redirect needed.
1336        if schema.is_mlx() || schema.is_vllm_mlx() {
1337            return None;
1338        }
1339        // Only redirect local GGUF models.
1340        if !matches!(schema.source, ModelSource::Local { .. }) {
1341            return None;
1342        }
1343        // Find the MLX twin: same family, SAME PARAMETER COUNT, and at least
1344        // the same primary capability. Family alone is too coarse — every
1345        // Qwen3 size shares family "qwen3", so a family-only `.find()` could
1346        // map an 8B GGUF to a 4B MLX model (whichever the HashMap yielded
1347        // first), silently swapping model size at execution and routing.
1348        // Keying on param_count makes the resolution deterministic and 1:1.
1349        let primary_cap = schema.capabilities.first()?;
1350        self.models.values().find(|m| {
1351            m.is_mlx()
1352                && m.family == schema.family
1353                && m.param_count == schema.param_count
1354                && m.capabilities.contains(primary_cap)
1355        })
1356    }
1357
1358    /// Ensure a local model is downloaded, returning its local directory path.
1359    pub async fn ensure_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1360        // Report to the ambient sink rather than dropping the events on the
1361        // floor — see [`Self::ambient_progress`] (Parslee-ai/car#620). Still a
1362        // no-op sink unless an embedder opted in, so this is not a behaviour
1363        // change for anyone who hasn't.
1364        let sink = self.ambient_progress.clone();
1365        self.ensure_local_with_progress(id, &sink).await
1366    }
1367
1368    /// Route implicit-download progress to `sink`.
1369    ///
1370    /// Applies to acquisitions nobody explicitly requested — the ones
1371    /// [`ensure_local`](Self::ensure_local) performs mid-`generate` when the
1372    /// router lands on a model that isn't on disk. Explicit pulls take their
1373    /// sink as an argument and ignore this.
1374    pub fn set_ambient_progress(&mut self, sink: ProgressSink) {
1375        self.ambient_progress = sink;
1376    }
1377
1378    /// Like [`ensure_local`](Self::ensure_local) but drives a progress sink and
1379    /// enforces the acquisition lifecycle: a per-model lock (so this can't race
1380    /// a concurrent pull / remove / upgrade of the same model), a disk-space
1381    /// preflight, and `Started`/`Completed`/`Failed` events around the work.
1382    /// When every required local file is already complete, it returns that path
1383    /// under the lock without a preflight or acquisition events.
1384    pub async fn ensure_local_with_progress(
1385        &self,
1386        id: &str,
1387        sink: &ProgressSink,
1388    ) -> Result<PathBuf, InferenceError> {
1389        self.acquire_and_ensure(id, sink, false, None).await
1390    }
1391
1392    /// Download into a caller-created private staging directory. The caller
1393    /// atomically publishes that directory only after the complete acquisition
1394    /// succeeds, so a pre-existing same-name directory can never be promoted
1395    /// into CAR ownership by a pull.
1396    pub(crate) async fn ensure_local_with_progress_staged(
1397        &self,
1398        id: &str,
1399        sink: &ProgressSink,
1400        staging_dir: &Path,
1401    ) -> Result<PathBuf, InferenceError> {
1402        self.acquire_and_ensure(id, sink, false, Some(staging_dir))
1403            .await
1404    }
1405
1406    /// Force a complete re-acquisition: skip the "reuse what's already on disk"
1407    /// short-circuits so every expected file is re-checked and any missing one
1408    /// (e.g. a blob the self-heal path purged as provably corrupt) is
1409    /// re-downloaded. The per-file checks still skip intact files, so this
1410    /// re-fetches only what's actually gone — not the whole model.
1411    pub async fn redownload_local(&self, id: &str) -> Result<PathBuf, InferenceError> {
1412        self.acquire_and_ensure(id, &ProgressSink::none(), true, None)
1413            .await
1414    }
1415
1416    async fn acquire_and_ensure(
1417        &self,
1418        id: &str,
1419        sink: &ProgressSink,
1420        force: bool,
1421        managed_dir_override: Option<&Path>,
1422    ) -> Result<PathBuf, InferenceError> {
1423        let schema = self
1424            .get(id)
1425            .or_else(|| self.find_by_name(id))
1426            .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1427        let model_name = schema.name.clone();
1428        let model_id = schema.id.clone();
1429        let needed_mb = schema.size_mb();
1430        let model_dir = self.models_dir.join(&schema.name);
1431
1432        // Serialize acquisition of this model id against other pull/remove tasks.
1433        let _guard = crate::download::acquire_model_lock(&model_id).await;
1434
1435        // Reusing complete bytes touches neither disk nor the network, so it
1436        // must precede the size-plus-reserve preflight and acquisition events.
1437        // Forced repairs and staged pulls deliberately keep the full lifecycle.
1438        if !force && managed_dir_override.is_none() {
1439            if let Some(path) = self.try_reuse_local(schema).await? {
1440                return Ok(path);
1441            }
1442        }
1443
1444        // Preflight: fail fast if there isn't room, before touching disk.
1445        if let Err(e) = crate::download::check_disk_space(&model_dir, needed_mb) {
1446            sink.emit(DownloadEvent::Failed { error: e.clone() });
1447            return Err(InferenceError::DownloadFailed(e));
1448        }
1449
1450        sink.emit(DownloadEvent::Started {
1451            model: model_name.clone(),
1452            total_files: 0,
1453            total_mb: needed_mb,
1454        });
1455        // Record, don't infer: only the files this acquisition actually
1456        // fetched — never a snapshot it reused, which may be the user's own.
1457        let (result, fetched) = crate::retire::collecting_fetches(self.ensure_local_inner(
1458            id,
1459            sink,
1460            force,
1461            managed_dir_override,
1462        ))
1463        .await;
1464        crate::retire::record_download(
1465            &self.state_root,
1466            &crate::hf_cache::hub_dir(),
1467            &model_id,
1468            &fetched,
1469        );
1470        match &result {
1471            Ok(_) => sink.emit(DownloadEvent::Completed { model: model_name }),
1472            Err(e) => sink.emit(DownloadEvent::Failed {
1473                error: e.to_string(),
1474            }),
1475        }
1476        result
1477    }
1478
1479    async fn try_reuse_local(
1480        &self,
1481        schema: &ModelSchema,
1482    ) -> Result<Option<PathBuf>, InferenceError> {
1483        match &schema.source {
1484            ModelSource::Local { .. } => {
1485                let model_dir = self.models_dir.join(&schema.name);
1486                let model_path = model_dir.join("model.gguf");
1487                let tokenizer_path = model_dir.join("tokenizer.json");
1488
1489                if crate::download::cache_file_usable(&model_path)
1490                    && crate::download::cache_file_usable(&tokenizer_path)
1491                {
1492                    return Ok(Some(model_dir));
1493                }
1494            }
1495            ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
1496                let model_dir = self.models_dir.join(&schema.name);
1497                let is_diffusers = mlx_schema_is_diffusers(schema);
1498
1499                if mlx_layout_complete(is_diffusers, &model_dir) {
1500                    if auxiliary_mlx_files_missing(&schema.name, hf_repo, &model_dir) {
1501                        // Preserve managed-directory precedence: the inner path
1502                        // repairs this directory before considering a shared HF
1503                        // snapshot, just as it did before reuse moved earlier.
1504                        return Ok(None);
1505                    }
1506                    info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1507                    return Ok(Some(model_dir));
1508                }
1509
1510                if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1511                    .filter(|d| mlx_layout_complete(is_diffusers, d))
1512                {
1513                    if !auxiliary_mlx_files_missing(&schema.name, hf_repo, &snapshot_dir) {
1514                        info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1515                        return Ok(Some(snapshot_dir));
1516                    }
1517                }
1518            }
1519            _ => {}
1520        }
1521
1522        Ok(None)
1523    }
1524
1525    async fn ensure_local_inner(
1526        &self,
1527        id: &str,
1528        sink: &ProgressSink,
1529        force: bool,
1530        managed_dir_override: Option<&Path>,
1531    ) -> Result<PathBuf, InferenceError> {
1532        let schema = self
1533            .get(id)
1534            .or_else(|| self.find_by_name(id))
1535            .ok_or_else(|| InferenceError::ModelNotFound(id.to_string()))?;
1536
1537        match &schema.source {
1538            ModelSource::Local {
1539                hf_repo,
1540                hf_filename,
1541                tokenizer_repo,
1542            } => {
1543                let model_dir = managed_dir_override
1544                    .map(Path::to_path_buf)
1545                    .unwrap_or_else(|| self.models_dir.join(&schema.name));
1546                let model_path = model_dir.join("model.gguf");
1547                let tokenizer_path = model_dir.join("tokenizer.json");
1548
1549                if !force
1550                    && crate::download::cache_file_usable(&model_path)
1551                    && crate::download::cache_file_usable(&tokenizer_path)
1552                {
1553                    return Ok(model_dir);
1554                }
1555
1556                // A row with no `hf_repo` came from the local-directory scan:
1557                // its weights are whatever the user put on disk, and there is
1558                // nowhere to fetch the missing pieces from. Downloading with an
1559                // empty repo asks HuggingFace for `https://huggingface.co//…`
1560                // and reports whatever that returns, which explains nothing.
1561                // Say what CAR actually needs instead.
1562                if hf_repo.is_empty() {
1563                    let missing = [
1564                        ("model.gguf", &model_path),
1565                        ("tokenizer.json", &tokenizer_path),
1566                    ]
1567                    .into_iter()
1568                    .filter(|(_, path)| !crate::download::cache_file_usable(path))
1569                    .map(|(name, _)| name)
1570                    .collect::<Vec<_>>()
1571                    .join(" and ");
1572                    return Err(InferenceError::InferenceFailed(format!(
1573                        "{}: discovered on disk at {} but not loadable — the GGUF \
1574                         backend reads `model.gguf` and `tokenizer.json` from the \
1575                         model directory, and this one is missing {missing}. Rename \
1576                         the weight file to `model.gguf` and add the tokenizer, or \
1577                         register the model against its HuggingFace repo so CAR can \
1578                         fetch both.",
1579                        schema.name,
1580                        model_dir.display()
1581                    )));
1582                }
1583
1584                std::fs::create_dir_all(&model_dir)?;
1585
1586                if !crate::download::cache_file_usable(&model_path) {
1587                    info!(model = %schema.name, repo = %hf_repo, "downloading model weights");
1588                    sink.emit(DownloadEvent::FileStarted {
1589                        filename: "model weights".into(),
1590                        index: 1,
1591                        total_files: 2,
1592                        size_mb: schema.size_mb(),
1593                    });
1594                    download_file(hf_repo, hf_filename, &model_path).await?;
1595                    sink.emit(DownloadEvent::FileCompleted {
1596                        filename: "model weights".into(),
1597                    });
1598                }
1599                if !crate::download::cache_file_usable(&tokenizer_path) {
1600                    info!(model = %schema.name, repo = %tokenizer_repo, "downloading tokenizer");
1601                    sink.emit(DownloadEvent::FileStarted {
1602                        filename: "tokenizer".into(),
1603                        index: 2,
1604                        total_files: 2,
1605                        size_mb: 0,
1606                    });
1607                    download_file(tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
1608                    sink.emit(DownloadEvent::FileCompleted {
1609                        filename: "tokenizer".into(),
1610                    });
1611                }
1612
1613                Ok(model_dir)
1614            }
1615            ModelSource::Mlx {
1616                hf_repo,
1617                hf_weight_file,
1618            }
1619            | ModelSource::ManagedVllmMlx {
1620                hf_repo,
1621                hf_weight_file,
1622            } => {
1623                let model_dir = managed_dir_override
1624                    .map(Path::to_path_buf)
1625                    .unwrap_or_else(|| self.models_dir.join(&schema.name));
1626                let config_path = model_dir.join("config.json");
1627
1628                // Diffusers-layout models (Flux image-gen, LTX video) keep their
1629                // weights in component subdirs (transformer/, vae/, text_encoder/,
1630                // tokenizer/) and ship NO root config.json / top-level weight
1631                // index. The standard MLX flow below hard-fetches config.json
1632                // (404 for these repos) and a weight index (absent), so they need
1633                // the whole-repo snapshot path. Detect by capability so DOWNLOAD,
1634                // REUSE, and (via mlx_dir_has_weights) LOAD all agree on the
1635                // layout — the recurring image-gen 404 was fixing only the load
1636                // path (recurse subdirs) while download + reuse still demanded a
1637                // config.json these repos don't have.
1638                let is_diffusers = mlx_schema_is_diffusers(schema);
1639
1640                // Reuse the managed dir ONLY if it actually holds weights — a
1641                // config-only stub (or dangling-symlink install) must fall
1642                // through to the download below, not no-op (car-releases#391).
1643                // `force` (self-heal re-pull) skips reuse so a purged shard is
1644                // actually re-fetched rather than masked by the surviving shards.
1645                // Standard MLX also gates on a non-empty root config.json, the
1646                // same bar `download_file` applies per file, so a zero-byte or
1647                // dangling config is re-fetched below instead of reused;
1648                // diffusers has none, so recursive weights-present is its
1649                // completeness signal.
1650                if !force && mlx_layout_complete(is_diffusers, &model_dir) {
1651                    ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1652                    info!(model = %schema.name, path = %model_dir.display(), "using managed local MLX model");
1653                    return Ok(model_dir);
1654                }
1655
1656                // Same guard for a cached HF snapshot: a partial/pruned snapshot
1657                // whose config or weights don't resolve isn't usable —
1658                // re-download instead.
1659                if !force {
1660                    if let Some(snapshot_dir) = latest_huggingface_repo_snapshot(hf_repo)
1661                        .filter(|d| mlx_layout_complete(is_diffusers, d))
1662                    {
1663                        ensure_auxiliary_mlx_files(&schema.name, hf_repo, &snapshot_dir).await?;
1664                        info!(model = %schema.name, path = %snapshot_dir.display(), "using cached MLX snapshot");
1665                        return Ok(snapshot_dir);
1666                    }
1667                }
1668
1669                std::fs::create_dir_all(&model_dir)?;
1670
1671                info!(model = %schema.name, repo = %hf_repo, "downloading MLX model");
1672
1673                // Diffusers-layout: mirror the whole repo (component subdirs
1674                // preserved). There is no root config.json or weight index to
1675                // drive the file-by-file flow below, and demanding one is the
1676                // 404 that broke image/video generation. Verify component weights
1677                // actually landed so a half-finished pull can't masquerade as
1678                // installed (the false-availability the caller relies on).
1679                if is_diffusers {
1680                    download_repo_snapshot(hf_repo, &model_dir, sink).await?;
1681                    ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1682                    if !mlx_dir_has_weights(&model_dir) {
1683                        return Err(InferenceError::DownloadFailed(format!(
1684                            "{hf_repo}: snapshot fetched but no component weights found"
1685                        )));
1686                    }
1687                    info!(model = %schema.name, path = %model_dir.display(), "downloaded diffusers model");
1688                    return Ok(model_dir);
1689                }
1690
1691                // Download config, tokenizer, and weight files. total_files is
1692                // not known up front for MLX (single vs sharded), so events
1693                // carry total_files = 0 ("unknown") and the UI shows names.
1694                emit_file(sink, "config", 0, schema.size_mb());
1695                download_file(hf_repo, "config.json", &config_path).await?;
1696                download_tokenizer_assets(hf_repo, &model_dir, sink).await;
1697                let tok_config_path = model_dir.join("tokenizer_config.json");
1698                if !crate::download::cache_file_usable(&tok_config_path) {
1699                    let _ = download_file(hf_repo, "tokenizer_config.json", &tok_config_path).await;
1700                }
1701
1702                // Download weight files
1703                if let Some(ref wf) = hf_weight_file {
1704                    let wf_path = model_dir.join(wf);
1705                    if !crate::download::cache_file_usable(&wf_path) {
1706                        emit_file(sink, "model weights", 0, schema.size_mb());
1707                        download_file(hf_repo, wf, &wf_path).await?;
1708                    }
1709                } else {
1710                    // Try single file first, then sharded
1711                    let single = model_dir.join("model.safetensors");
1712                    if !crate::download::cache_file_usable(&single) {
1713                        emit_file(sink, "model weights", 0, schema.size_mb());
1714                        match download_file(hf_repo, "model.safetensors", &single).await {
1715                            Ok(()) => {}
1716                            Err(_) => {
1717                                // Sharded: download index and then each shard
1718                                let index_path = model_dir.join("model.safetensors.index.json");
1719                                download_file(hf_repo, "model.safetensors.index.json", &index_path)
1720                                    .await?;
1721
1722                                let index_json: serde_json::Value =
1723                                    serde_json::from_str(&std::fs::read_to_string(&index_path)?)
1724                                        .map_err(|e| {
1725                                            InferenceError::InferenceFailed(format!(
1726                                                "parse index: {e}"
1727                                            ))
1728                                        })?;
1729
1730                                if let Some(weight_map) =
1731                                    index_json.get("weight_map").and_then(|m| m.as_object())
1732                                {
1733                                    let mut files: std::collections::HashSet<String> =
1734                                        std::collections::HashSet::new();
1735                                    for filename in weight_map.values() {
1736                                        if let Some(f) = filename.as_str() {
1737                                            files.insert(f.to_string());
1738                                        }
1739                                    }
1740                                    let shard_total = files.len() as u32;
1741                                    for (i, file) in files.iter().enumerate() {
1742                                        let dest = model_dir.join(file);
1743                                        if !crate::download::cache_file_usable(&dest) {
1744                                            info!(file = %file, "downloading weight shard");
1745                                            sink.emit(DownloadEvent::FileStarted {
1746                                                filename: format!("weights part {}", i + 1),
1747                                                index: (i + 1) as u32,
1748                                                total_files: shard_total,
1749                                                size_mb: 0,
1750                                            });
1751                                            download_file(hf_repo, file, &dest).await?;
1752                                            sink.emit(DownloadEvent::FileCompleted {
1753                                                filename: format!("weights part {}", i + 1),
1754                                            });
1755                                        }
1756                                    }
1757                                }
1758                            }
1759                        }
1760                    }
1761                }
1762
1763                ensure_auxiliary_mlx_files(&schema.name, hf_repo, &model_dir).await?;
1764
1765                // Verify what we actually landed, exactly as the diffusers branch
1766                // above does (#808). Without this, a pull whose shard fetch was
1767                // interrupted returned `Ok(model_dir)` — indistinguishable from
1768                // success — and the model stayed broken until inference failed
1769                // with `load model-00001-of-00002.safetensors: Path must point to
1770                // a local file`, inside whatever job was running.
1771                //
1772                // A caller reaching for the two obvious mechanisms — check
1773                // availability, then pull — got "yes" and "ok" from both while
1774                // the model was unusable. Report the missing shards by name so
1775                // the failure is actionable at pull time.
1776                let missing = missing_weight_shards(&model_dir);
1777                if !missing.is_empty() {
1778                    return Err(InferenceError::DownloadFailed(format!(
1779                        "{}: pull finished but {} weight shard(s) are still missing: {}. \
1780                         The download was interrupted; re-run the pull to resume it.",
1781                        schema.name,
1782                        missing.len(),
1783                        missing.join(", ")
1784                    )));
1785                }
1786                if !mlx_dir_has_weights(&model_dir) {
1787                    return Err(InferenceError::DownloadFailed(format!(
1788                        "{}: pull finished but no usable weights are present under {}",
1789                        schema.name,
1790                        model_dir.display()
1791                    )));
1792                }
1793                Ok(model_dir)
1794            }
1795            _ => Err(InferenceError::InferenceFailed(format!(
1796                "model {} is not local",
1797                id
1798            ))),
1799        }
1800    }
1801
1802    /// Legacy registry-local deletion is intentionally disabled because it
1803    /// recursively removed shared Hugging Face caches without receipts or
1804    /// runtime/lease coordination. Use `InferenceEngine::remove_model_from_car`.
1805    #[deprecated(note = "use InferenceEngine::remove_model_from_car")]
1806    pub fn remove_local(&mut self, id: &str) -> Result<(), InferenceError> {
1807        Err(InferenceError::InferenceFailed(format!(
1808            "legacy registry removal for {id} is disabled; use receipt-backed model management"
1809        )))
1810    }
1811
1812    /// Refresh passive catalog availability flags for all models.
1813    ///
1814    /// Physical local/runtime probes remain live, but credential-backed rows
1815    /// use only environment presence, the non-secret Parslee authority hint,
1816    /// and the process-local Codex sign-in hint. This method is safe for
1817    /// startup, catalog, setup, and health surfaces.
1818    pub fn refresh_availability(&mut self) {
1819        let parslee_oauth_available = self.session.available();
1820        self.refresh_availability_with(
1821            parslee_oauth_available,
1822            self.session.signed_out() && self.session.may_forget_session_evidence(),
1823            false,
1824        );
1825    }
1826
1827    /// Refresh availability for an **explicit status request**, resolving
1828    /// credentials authoritatively (environment *or* OS keychain).
1829    ///
1830    /// [`Self::refresh_availability`] is deliberately passive — environment
1831    /// presence only — because it runs on startup and catalog paths where a
1832    /// physical secret-store read per provider would be wrong. That is right
1833    /// for those callers and wrong for a user who typed `car models list`:
1834    /// with a key held in `car secrets` rather than exported, the passive
1835    /// verdict reports a perfectly usable model as `RUNNABLE no`. Measured on
1836    /// a machine with `OPENAI_API_KEY` in the keychain and nothing in the
1837    /// environment, the embedded list path reported `openai/gpt-5.5` as not
1838    /// runnable while inference through the same binary worked.
1839    ///
1840    /// Use this only for surfaces a person explicitly asked for a status
1841    /// from, never on a startup or catalog path. It performs one secret-store
1842    /// read per DISTINCT credential name (deduplicated in
1843    /// `refresh_availability_with`), not one per model.
1844    pub fn refresh_availability_for_explicit_status(&mut self) {
1845        let parslee_oauth_available = self.session.available();
1846        self.refresh_availability_with(
1847            parslee_oauth_available,
1848            self.session.signed_out() && self.session.may_forget_session_evidence(),
1849            true,
1850        );
1851    }
1852
1853    /// Refresh availability for an explicit request-time routing/use path.
1854    /// `parslee_api_base` comes from Task 2's coordinator-owned credential
1855    /// snapshot; `parslee_signed_out` is true only for an authoritative absence,
1856    /// never for denial/cooldown/unreadable failures.
1857    pub(crate) fn refresh_routing_availability(
1858        &mut self,
1859        parslee_api_base: Option<&str>,
1860        parslee_signed_out: bool,
1861    ) {
1862        if let Some(api_base) = parslee_api_base {
1863            let api_base = api_base.trim_end_matches('/');
1864            for schema in self.models.values_mut() {
1865                if schema.provider.eq_ignore_ascii_case("parslee") {
1866                    if let ModelSource::Proprietary {
1867                        provider, endpoint, ..
1868                    } = &mut schema.source
1869                    {
1870                        if provider.eq_ignore_ascii_case("parslee") {
1871                            *endpoint = api_base.to_string();
1872                        }
1873                    }
1874                }
1875            }
1876        }
1877        self.refresh_availability_with(parslee_api_base.is_some(), parslee_signed_out, true);
1878    }
1879
1880    /// Request-local OAuth availability must not inherit a different organization's
1881    /// credential or gateway verdict. Other providers retain their usual checks.
1882    pub(crate) fn refresh_work_context_availability(&mut self, api_base: &str, available: bool) {
1883        self.refresh_availability();
1884        for schema in self.models.values_mut() {
1885            if schema.provider.eq_ignore_ascii_case("parslee") {
1886                if let ModelSource::Proprietary {
1887                    provider,
1888                    endpoint,
1889                    auth: ProprietaryAuth::OAuth2Pkce { .. },
1890                    ..
1891                } = &mut schema.source
1892                {
1893                    if provider.eq_ignore_ascii_case("parslee") {
1894                        *endpoint = api_base.trim_end_matches('/').to_string();
1895                        schema.available = available;
1896                    }
1897                }
1898            }
1899        }
1900    }
1901
1902    /// Whether the last authoritative refresh failed to determine this
1903    /// credential variable's state, as opposed to finding it absent.
1904    pub fn credential_state_is_unknown(&self, env_var: &str) -> bool {
1905        self.credential_state_unknown.contains(env_var)
1906    }
1907
1908    fn refresh_availability_with(
1909        &mut self,
1910        parslee_oauth_available: bool,
1911        clear_parslee_observations: bool,
1912        authoritative_credentials: bool,
1913    ) {
1914        // `models_dir` is consumed only inside the MLX and Local arms.
1915        // On non-MLX targets the MLX arm is a cfg-gated `available = false`
1916        // and never touches `models_dir`; the Local arm still needs it.
1917        let models_dir = self.models_dir.clone();
1918        // mlx-vlm CLI is the same probe call no matter which model
1919        // requires it; do it once per refresh, not per-model. On
1920        // non-MLX targets the variable is unused (the consuming arm
1921        // is cfg-gated out), so suppress the unused-variable warning.
1922        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1923        let mlx_vlm_cli_present = crate::backend::mlx_vlm_cli::is_available();
1924        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1925        #[allow(unused_variables)]
1926        let mlx_vlm_cli_present = false;
1927        // A learned "gateway has no OpenRouter upstream" is about the
1928        // environment behind THIS session. When the session is gone, so is the
1929        // evidence — otherwise a sign-in to a properly-configured org would
1930        // inherit the previous one's suppression (Parslee-ai/car#786).
1931        if clear_parslee_observations {
1932            crate::openrouter::clear_gateway_unconfigured();
1933            // Same reasoning for the credential verdict: it was learned about
1934            // the credential that is now gone, and inheriting it would suppress
1935            // the managed lanes through the next sign-in (Parslee-ai/car#887).
1936            crate::parslee_credential::clear_credential_rejected();
1937        }
1938
1939        // Resolve each DISTINCT credential once, not once per model
1940        // (car-releases#75).
1941        //
1942        // Every `RemoteApi` and `Proprietary` row used to call
1943        // `resolve_env_or_keychain` inside the loop below, and on macOS that is
1944        // a keychain query — ~14 ms each. A stock catalog is ~72 models over
1945        // roughly a dozen providers, so a refresh spent ~1 s asking the keychain
1946        // the same handful of questions dozens of times. `routing_registry_
1947        // snapshot` runs a refresh per request AND `estimated_tokens` runs
1948        // another, so a single delegated call paid it twice: ~2 s before the
1949        // request reached the runner, which is the reported latency.
1950        //
1951        // Deduplicating changes no semantics. Within ONE refresh the same env
1952        // var must yield the same answer — reading it 30 times cannot be more
1953        // correct than reading it once. Credentials are still re-read on every
1954        // refresh, so the "pasted/OAuth key changes take effect without a
1955        // restart" property above is untouched.
1956        let mut credential_envs: std::collections::BTreeSet<String> = Default::default();
1957        let mut needs_openrouter = false;
1958        let mut needs_codex = false;
1959        for m in self.models.values() {
1960            match &m.source {
1961                ModelSource::RemoteApi {
1962                    protocol: crate::schema::ApiProtocol::OpenRouter,
1963                    ..
1964                } => needs_openrouter = true,
1965                ModelSource::RemoteApi { api_key_env, .. } => {
1966                    credential_envs.insert(api_key_env.clone());
1967                }
1968                ModelSource::Proprietary { auth, .. } => match auth {
1969                    ProprietaryAuth::ApiKeyEnv { env_var }
1970                    | ProprietaryAuth::BearerTokenEnv { env_var } => {
1971                        credential_envs.insert(env_var.clone());
1972                    }
1973                    ProprietaryAuth::ChatGptSubscription {} => needs_codex = true,
1974                    ProprietaryAuth::OAuth2Pkce { .. } => {}
1975                },
1976                _ => {}
1977            }
1978        }
1979        let mut credential_unknown: std::collections::HashSet<String> =
1980            std::collections::HashSet::new();
1981        // Carried on the registry rather than on each row: `ModelSchema` is
1982        // catalog content and this is per-refresh runtime state.  See
1983        // `credential_state_unknown` for what reads it.
1984        // The Codex record is not an environment credential. Explicit status
1985        // reads check its existence once through car-auth and refresh the
1986        // process-local hint; passive catalog reads use only that hint.
1987        if authoritative_credentials && needs_codex {
1988            remember_codex_sign_in(car_auth::codex_credential_present_without_reading_it());
1989        }
1990        let credential_available: std::collections::HashMap<String, bool> = credential_envs
1991            .into_iter()
1992            .map(|env| {
1993                let available = if authoritative_credentials {
1994                    // A store failure is not absence. The row still reads
1995                    // unavailable -- conservative for a runnability claim --
1996                    // but it must not go on to advise adding a key (car#1920).
1997                    match credential_presence(&env) {
1998                        CredentialPresence::Present => true,
1999                        CredentialPresence::Absent => false,
2000                        CredentialPresence::Unknown => {
2001                            credential_unknown.insert(env.clone());
2002                            false
2003                        }
2004                    }
2005                } else {
2006                    environment_credential_available(&env)
2007                };
2008                (env, available)
2009            })
2010            .collect();
2011        self.credential_state_unknown = credential_unknown;
2012        // Only probe OpenRouter when a row actually needs it — an unnecessary
2013        // probe is the same class of waste this block exists to remove.
2014        let openrouter_available = needs_openrouter
2015            && if authoritative_credentials {
2016                crate::openrouter::refresh_credential_source().is_some()
2017            } else {
2018                crate::openrouter::credential_source().is_some()
2019            };
2020
2021        for m in self.models.values_mut() {
2022            match &m.source {
2023                ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2024                    // MLX requires Apple Silicon Metal. On any other
2025                    // build target — Intel Mac, Linux, Windows, or
2026                    // `car_skip_mlx` — the backend is not compiled in
2027                    // and the adaptive router must not select these
2028                    // models. Cfg-gate identical to the
2029                    // AppleFoundationModels branch below and the
2030                    // `register()` MLX branch above. Closes the §7.1
2031                    // arm of Parslee-ai/car#231.
2032                    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2033                    {
2034                        // Models tagged `requires-mlx-vlm` shell out to the
2035                        // mlx_vlm Python CLI for image inference (#115).
2036                        // If the CLI isn't on PATH, the runtime reaches it
2037                        // anyway and bails — the registry MUST reflect that
2038                        // by marking such entries unavailable until the
2039                        // user installs `uv tool install mlx-vlm`. #137.
2040                        let needs_mlx_vlm = m.tags.iter().any(|t| t == "requires-mlx-vlm");
2041
2042                        // Only a row the in-process loader runs needs the
2043                        // Swift MLX stack linked into THIS binary; the target
2044                        // cfg does not imply it (`CAR_BUILD_SWIFT_LM=0`, or a
2045                        // Swift build that failed and warned). Both availability
2046                        // paths ask the same helper, because they are required
2047                        // to agree — a model whose `available` flips depending
2048                        // on which ran last is worse than either answer.
2049                        let in_process_loader_present = !mlx_row_needs_in_process_loader(m)
2050                            || crate::backend::local::in_process_loader_available();
2051
2052                        m.available = if needs_mlx_vlm {
2053                            mlx_vlm_cli_present
2054                        } else if m.tags.contains(&"speech".to_string()) {
2055                            speech_mlx_available()
2056                        } else {
2057                            // Available if cached locally OR has an hf_repo —
2058                            // ensure_local() lazy-downloads on first use, so a
2059                            // declared hf_repo is "functionally available" the
2060                            // same way Ollama and RemoteApi entries are
2061                            // ("should work in principle" not "physically
2062                            // cached"). Closes #164: mlx/ltx-2.3:q4 reported
2063                            // unavailable even though `car video` would just
2064                            // download-and-run successfully.
2065                            let mlx_dir = models_dir.join(&m.name);
2066                            in_process_loader_present
2067                                && (mlx_dir_has_weights(&mlx_dir) || !hf_repo.is_empty())
2068                        };
2069                    }
2070                    #[cfg(not(all(
2071                        target_os = "macos",
2072                        target_arch = "aarch64",
2073                        not(car_skip_mlx)
2074                    )))]
2075                    {
2076                        let _ = hf_repo; // unused on non-MLX targets
2077                        m.available = false;
2078                    }
2079                }
2080                ModelSource::Local {
2081                    hf_repo: local_repo,
2082                    ..
2083                } => {
2084                    let local_path = models_dir.join(&m.name).join("model.gguf");
2085                    // Where the Candle GGUF backend exists — every target that
2086                    // is not Apple-Silicon-with-MLX, i.e. the CUDA machines
2087                    // where GGUF *is* the local path — a declared `hf_repo` is
2088                    // functionally available, because `ensure_local` fetches
2089                    // `model.gguf` and `tokenizer.json` from it on first use.
2090                    // Same rule #164 gave the MLX path and whisper.cpp already
2091                    // has; this branch was left behind, so a fresh CUDA install
2092                    // reported every local model unavailable, the router
2093                    // dropped them from fallback chains, and the user was sent
2094                    // to cloud with a GPU sitting idle one download away.
2095                    //
2096                    // An empty `hf_repo` means a row from the local-directory
2097                    // scan, which has nowhere to fetch from and stays gated on
2098                    // the file actually being there.
2099                    #[cfg(not(all(
2100                        target_os = "macos",
2101                        target_arch = "aarch64",
2102                        not(car_skip_mlx)
2103                    )))]
2104                    {
2105                        m.available = local_path.exists() || !local_repo.is_empty();
2106                    }
2107                    // On Apple Silicon `backend::candle` is not compiled at
2108                    // all: a GGUF row runs only by `resolve_mlx_equivalent`
2109                    // substitution, so it stays gated on physical presence
2110                    // rather than claiming a lazy download that has no loader.
2111                    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2112                    {
2113                        let _ = local_repo;
2114                        m.available = local_path.exists();
2115                    }
2116                }
2117                ModelSource::WhisperCpp { .. } => {
2118                    // whisper.cpp compiles on every platform and lazy-downloads
2119                    // its ggml model on first use (car-whisper), so it is
2120                    // functionally available everywhere — the cross-platform
2121                    // on-device STT the MLX speech models can't be off Apple.
2122                    m.available = true;
2123                }
2124                ModelSource::WindowsSpeech {} => {
2125                    // OS-provided WinRT synthesizer — available on Windows only
2126                    // (like AppleFoundationModels is Apple-only).
2127                    #[cfg(target_os = "windows")]
2128                    {
2129                        m.available = true;
2130                    }
2131                    #[cfg(not(target_os = "windows"))]
2132                    {
2133                        m.available = false;
2134                    }
2135                }
2136                ModelSource::RemoteApi {
2137                    protocol: crate::schema::ApiProtocol::OpenRouter,
2138                    ..
2139                } => {
2140                    m.available = openrouter_available;
2141                }
2142                ModelSource::RemoteApi { api_key_env, .. } => {
2143                    // env OR keychain — see `register`. Resolved once per
2144                    // refresh above, not once per model (car-releases#75).
2145                    m.available = credential_available
2146                        .get(api_key_env)
2147                        .copied()
2148                        .unwrap_or(false);
2149                }
2150                ModelSource::CodexCli { .. } => {
2151                    m.available = crate::backend::codex_cli::is_available();
2152                }
2153                ModelSource::Ollama { .. } => {
2154                    // Assume available; health check is async and done lazily
2155                    m.available = true;
2156                }
2157                ModelSource::VllmMlx { .. } => {
2158                    // External endpoints remain server-provided even on
2159                    // loopback. CAR's managed runtime says nothing about the
2160                    // health or ownership of this separately registered server.
2161                    m.available = std::env::var("VLLM_MLX_ENDPOINT").is_ok() || m.available;
2162                }
2163                ModelSource::Proprietary { provider, auth, .. } => {
2164                    m.available = proprietary_auth_available(
2165                        &m.id,
2166                        &m.provider,
2167                        provider,
2168                        auth,
2169                        parslee_oauth_available,
2170                        &credential_available,
2171                    );
2172                }
2173                ModelSource::AppleFoundationModels { .. } => {
2174                    // Apple Silicon macOS 26+ AND iOS 26+ both expose
2175                    // the FoundationModels framework. The shim's
2176                    // runtime probe handles per-device availability
2177                    // (Apple Intelligence may be off, the device may
2178                    // be pre-A17, etc.); cfg-gating here just hides
2179                    // the call on targets where the shim isn't built.
2180                    #[cfg(any(
2181                        all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2182                        all(target_os = "ios", target_arch = "aarch64")
2183                    ))]
2184                    {
2185                        m.available = crate::backend::foundation_models::is_available();
2186                        // Parallel tool calls are a HOST property here, not a
2187                        // catalog one: the same shim yields one captured call
2188                        // per turn on macOS 26 and every proposed call on 27,
2189                        // because the framework's post-throw batch behaviour
2190                        // differs between them. The catalog literal stays
2191                        // conservative and the row is upgraded only where the
2192                        // behaviour was measured, so a host that cannot do it
2193                        // never advertises it.
2194                        //
2195                        // Precedent: `available` on the line above is already
2196                        // host-dependent at this same hook, so a capability
2197                        // that varies by host is not a new shape for this row.
2198                        if crate::backend::foundation_models::supports_parallel_tool_calls()
2199                            && !m.capabilities.contains(&ModelCapability::MultiToolCall)
2200                        {
2201                            m.capabilities.push(ModelCapability::MultiToolCall);
2202                        }
2203                        // Vision, same shape, one extra constraint: the claim is
2204                        // only made where the request can actually be SERVED.
2205                        // `supports_vision()` reads the model's own
2206                        // `LanguageModelCapabilities`, and the only path that
2207                        // consumes images — `generate_with_images` — is gated on
2208                        // the same probe. Claiming vision without that path is
2209                        // the 2b bug again: a route the router picks and the
2210                        // backend then fails at generation time.
2211                        if crate::backend::foundation_models::supports_vision()
2212                            && !m.capabilities.contains(&ModelCapability::Vision)
2213                        {
2214                            m.capabilities.push(ModelCapability::Vision);
2215                        }
2216                        // Prefer the framework's own answer over the catalog
2217                        // literal. `contextSize` exists from 26.4 on, so this
2218                        // is None on 26.0-26.3 and the declared value stands.
2219                        // The literal over-claiming the window is not cosmetic:
2220                        // `build_context_for_model` sizes the assembly budget
2221                        // from it, so a wrong number fails at generation time
2222                        // with the assembly already paid for.
2223                        if let Some(window) = crate::backend::foundation_models::context_size() {
2224                            m.context_length = window as usize;
2225                        }
2226                    }
2227                    #[cfg(not(any(
2228                        all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
2229                        all(target_os = "ios", target_arch = "aarch64")
2230                    )))]
2231                    {
2232                        m.available = false;
2233                    }
2234                }
2235                ModelSource::Delegated { .. } => {
2236                    // Availability tracks whether a runner is registered.
2237                    // Hosts call `registerInferenceRunner` (or its
2238                    // language equivalent) at startup; until then the
2239                    // model is unavailable.
2240                    m.available = crate::runner::current_inference_runner().is_some();
2241                }
2242            }
2243            m.weights_ready = physical_weights_ready(m, &models_dir);
2244        }
2245    }
2246
2247    /// Persist only explicitly user-registered models to disk.
2248    pub fn save_user_config(&self) -> Result<(), InferenceError> {
2249        let mut user_models: Vec<ModelSchema> = self
2250            .user_config_ids
2251            .iter()
2252            .filter_map(|id| self.models.get(id))
2253            .cloned()
2254            .map(|mut model| {
2255                // `models.json` is a user-controlled trust boundary regardless
2256                // of how the in-memory row was originally registered.
2257                model.mark_user_registered();
2258                model
2259            })
2260            .collect();
2261        user_models.sort_by(|a, b| a.id.cmp(&b.id));
2262
2263        for model in &user_models {
2264            crate::catalog_identity::row_digest(model).map_err(|error| {
2265                InferenceError::InferenceFailed(format!(
2266                    "refuse to persist model without canonical catalog identity: {error}"
2267                ))
2268            })?;
2269        }
2270
2271        let json = serde_json::to_string_pretty(&user_models)
2272            .map_err(|e| InferenceError::InferenceFailed(format!("serialize: {e}")))?;
2273        std::fs::write(&self.user_config_path, json)?;
2274        Ok(())
2275    }
2276
2277    /// Load user-registered models from disk.
2278    pub fn load_user_config(&mut self) -> Result<(), InferenceError> {
2279        if !self.user_config_path.exists() {
2280            return Ok(());
2281        }
2282
2283        let json = std::fs::read_to_string(&self.user_config_path)?;
2284        let models: Vec<ModelSchema> = serde_json::from_str(&json)
2285            .map_err(|e| InferenceError::InferenceFailed(format!("parse models.json: {e}")))?;
2286
2287        for m in models {
2288            // Legacy rows omit `trust_tier` and serde must keep accepting that
2289            // shape, but a manual config can never confer project curation.
2290            self.register_user_model(m);
2291        }
2292        Ok(())
2293    }
2294
2295    /// Get the models directory path.
2296    pub fn models_dir(&self) -> &Path {
2297        &self.models_dir
2298    }
2299
2300    /// Whether invoking this model can start without CAR-managed model setup.
2301    ///
2302    /// `available` deliberately includes lazy-downloadable local models so
2303    /// normal CLI inference can pull on first use. Interactive host actions
2304    /// need the stricter answer: avoid starting a large download from a
2305    /// ready-to-use assistant button and point the user at Models setup
2306    /// instead.
2307    pub fn ready_without_download(&self, id: &str) -> Option<bool> {
2308        let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2309        Some(match &schema.source {
2310            ModelSource::Local { .. } => {
2311                let model_dir = self.models_dir.join(&schema.name);
2312                crate::download::cache_file_usable(&model_dir.join("model.gguf"))
2313                    && crate::download::cache_file_usable(&model_dir.join("tokenizer.json"))
2314            }
2315            ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2316                let managed_dir = self.models_dir.join(&schema.name);
2317                let managed_ready = mlx_snapshot_complete(schema, &managed_dir)
2318                    && crate::download::cache_file_usable(&managed_dir.join("tokenizer.json"));
2319                let snapshot_ready =
2320                    latest_huggingface_repo_snapshot(hf_repo).is_some_and(|snapshot| {
2321                        mlx_snapshot_complete(schema, &snapshot)
2322                            && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2323                    });
2324                managed_ready || snapshot_ready
2325            }
2326            ModelSource::WindowsSpeech {} => true, // OS-provided; nothing to download
2327            ModelSource::WhisperCpp { model } => {
2328                // "ready without download" = the ggml file is physically cached
2329                // at ~/.tokhn/whisper/. (Availability is looser — whisper always
2330                // lazy-downloads — but this is the strict host-action answer.)
2331                car_whisper::model_cached(model)
2332            }
2333            ModelSource::RemoteApi { .. }
2334            | ModelSource::CodexCli { .. }
2335            | ModelSource::Ollama { .. }
2336            | ModelSource::VllmMlx { .. }
2337            | ModelSource::AppleFoundationModels { .. }
2338            | ModelSource::Proprietary { .. }
2339            | ModelSource::Delegated { .. } => true,
2340        })
2341    }
2342
2343    /// Resolve a usable local artifact without downloading anything. This is
2344    /// the only source path accepted by explicit model adoption; callers do
2345    /// not get to supply a filesystem path.
2346    pub fn existing_local_artifact(&self, id: &str) -> Option<PathBuf> {
2347        let schema = self.get(id).or_else(|| self.find_by_name(id))?;
2348        let managed = self.models_dir.join(&schema.name);
2349        if std::fs::symlink_metadata(&managed).is_ok()
2350            && self.ready_without_download(&schema.id) == Some(true)
2351        {
2352            return Some(managed);
2353        }
2354        match &schema.source {
2355            ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
2356                latest_huggingface_repo_snapshot(hf_repo).filter(|snapshot| {
2357                    mlx_snapshot_complete(schema, snapshot)
2358                        && crate::download::cache_file_usable(&snapshot.join("tokenizer.json"))
2359                })
2360            }
2361            _ => None,
2362        }
2363    }
2364
2365    /// Load the built-in Qwen3 catalog as ModelSchema objects.
2366    fn load_builtin_catalog(&mut self) {
2367        for schema in builtin_catalog() {
2368            let id = schema.id.clone();
2369            if self.register_project_model(schema) {
2370                self.builtin_model_ids.insert(id);
2371            }
2372        }
2373    }
2374}
2375
2376/// Build a `ModelSchema` for an uncatalogued local model directory, or
2377/// `None` if the directory isn't a recognized text-LLM checkpoint.
2378///
2379/// Recognizes two layouts: MLX (a `config.json` with a known causal-LM
2380/// `model_type` plus safetensors weights) and GGUF (a `*.gguf` weight
2381/// file). Capabilities are inferred conservatively from the directory
2382/// name — anything not positively identified as a text LLM is skipped so
2383/// speech/vision/image/video checkpoints are never mistaken for
2384/// generators. car-releases#62.
2385fn synthesize_local_schema(name: &str, dir: &Path) -> Option<ModelSchema> {
2386    let lower = name.to_ascii_lowercase();
2387
2388    // Hard skip directory names that are unambiguously non-text models —
2389    // these would be catastrophic to route as `Generate`.
2390    const NON_TEXT_HINTS: &[&str] = &[
2391        "vad",
2392        "whisper",
2393        "parakeet",
2394        "kokoro",
2395        "tts",
2396        "stt",
2397        "flux",
2398        "ltx",
2399        "yume",
2400        "sd-",
2401        "stable-diffusion",
2402        "wan",
2403        "mochi",
2404        "sana",
2405        "diffusion",
2406    ];
2407    if NON_TEXT_HINTS.iter().any(|h| lower.contains(h)) {
2408        return None;
2409    }
2410
2411    // Capabilities by name. Embedding / reranker checkpoints share the same
2412    // causal-LM architecture as generators, so the name is the only signal.
2413    let capabilities: Vec<ModelCapability> =
2414        if lower.contains("embedding") || lower.contains("embed") {
2415            vec![ModelCapability::Embed]
2416        } else if lower.contains("reranker") || lower.contains("rerank") {
2417            vec![ModelCapability::Rerank]
2418        } else {
2419            vec![
2420                ModelCapability::Generate,
2421                ModelCapability::Code,
2422                ModelCapability::Reasoning,
2423            ]
2424        };
2425
2426    // Locate weights and (for MLX) validate the architecture.
2427    let config_path = dir.join("config.json");
2428    let has_safetensors =
2429        dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists();
2430
2431    let (source, context_length, quantization) = if config_path.exists() && has_safetensors {
2432        // MLX layout. Only accept a recognized causal-LM model_type.
2433        let cfg: serde_json::Value = std::fs::read_to_string(&config_path)
2434            .ok()
2435            .and_then(|s| serde_json::from_str(&s).ok())?;
2436        let model_type = cfg
2437            .get("model_type")
2438            .and_then(|v| v.as_str())
2439            .unwrap_or("")
2440            .to_ascii_lowercase();
2441        // A floor, not the gate. This answers "does this directory hold a
2442        // causal-LM checkpoint?" — a question about the *layout*, asked while
2443        // scanning a local directory on every platform, so it cannot be
2444        // delegated to a macOS-only loader. The loader's own answer is added
2445        // below, which is what lets an architecture `mlx-swift-lm` knows about
2446        // be recognized here without anyone editing this list.
2447        const KNOWN_LLM_TYPES: &[&str] = &[
2448            "qwen",
2449            "qwen2",
2450            "qwen3",
2451            "qwen3_moe",
2452            "llama",
2453            "mistral",
2454            "mixtral",
2455            "gemma",
2456            "gemma2",
2457            "gemma3",
2458            "gemma4_unified",
2459            "gemma4_unified_text",
2460            "phi",
2461            "phi3",
2462            "phimoe",
2463            "starcoder2",
2464            "deepseek",
2465            "deepseek_v2",
2466            "internlm2",
2467            "cohere",
2468            "olmo",
2469        ];
2470        let recognized = KNOWN_LLM_TYPES.iter().any(|t| model_type == *t)
2471            || crate::backend::local::has_native_backend(&model_type);
2472        if !recognized {
2473            return None;
2474        }
2475        let ctx = cfg
2476            .get("max_position_embeddings")
2477            .and_then(|v| v.as_u64())
2478            .unwrap_or(32_768) as usize;
2479        // Same block `hf_schema::quantization_of` reads, and for the same
2480        // reason: `bits` alone cannot tell an affine group quant from an MX
2481        // float, and the group size is what a kernel has to match. This used
2482        // to emit `"4-bit"` while the other ingest path emitted `"4bit"` —
2483        // two spellings of one thing, in a field nothing could compare.
2484        let quant = cfg
2485            .get("quantization")
2486            .filter(|q| q.is_object())
2487            .and_then(|q| {
2488                let bits = q
2489                    .get("bits")
2490                    .and_then(|b| b.as_u64())
2491                    .and_then(|b| u8::try_from(b).ok());
2492                let group_size = q
2493                    .get("group_size")
2494                    .and_then(|g| g.as_u64())
2495                    .and_then(|g| u32::try_from(g).ok());
2496                let mode = q.get("mode").and_then(|m| m.as_str());
2497                crate::schema::Quantization::from_mlx_config(bits, group_size, mode)
2498            });
2499        (
2500            serde_json::json!({ "type": "mlx", "hf_repo": "" }),
2501            ctx,
2502            quant,
2503        )
2504    } else {
2505        let gguf = std::fs::read_dir(dir).ok().and_then(|rd| {
2506            rd.flatten().map(|e| e.path()).find(|p| {
2507                p.extension()
2508                    .and_then(|x| x.to_str())
2509                    .is_some_and(|x| x.eq_ignore_ascii_case("gguf"))
2510            })
2511        })?;
2512        // GGUF layout (Candle). file name carried for the Local source.
2513        let filename = gguf
2514            .file_name()
2515            .and_then(|n| n.to_str())
2516            .unwrap_or("model.gguf")
2517            .to_string();
2518        // A GGUF file names its own quantization and this path used to throw
2519        // that away, so a user's own local checkpoints were the one ingest
2520        // route that recorded nothing at all.
2521        let quant = crate::schema::Quantization::from_gguf_filename(&filename);
2522        (
2523            serde_json::json!({
2524                "type": "local",
2525                "hf_repo": "",
2526                "hf_filename": filename,
2527                "tokenizer_repo": "",
2528            }),
2529            4_096,
2530            quant,
2531        )
2532    };
2533
2534    let id = format!("local/{}", lower.replace(['/', ' '], "-"));
2535    serde_json::from_value(serde_json::json!({
2536        "id": id,
2537        "name": name,
2538        "provider": "local",
2539        "family": "local",
2540        "capabilities": capabilities,
2541        "context_length": context_length,
2542        "quantization": quantization,
2543        "source": source,
2544        "tags": ["auto-discovered"],
2545        "trust_tier": "community",
2546    }))
2547    .ok()
2548}
2549
2550/// Whether an MLX-sourced speech model can actually run: the managed
2551/// `mlx-audio` runtime has to be provisioned.
2552///
2553/// Platform-independent, and it used to answer an unconditional `true` on Apple
2554/// Silicon on the grounds that "speech uses native MLX backends — no Python CLI
2555/// needed". Those backends (`mlx_kokoro`, `mlx_parakeet`) were deleted with the
2556/// Swift migration, so that branch promised a path that no longer exists —
2557/// which is the same false `available: true` car#649 was filed about, reached
2558/// from the other side. The managed runtime is now the only way an
2559/// `mlx/kokoro-*`, `mlx/parakeet-*` or `mlx/qwen3-tts-*` entry runs anywhere.
2560///
2561/// Only `ModelSource::Mlx` / `ManagedVllmMlx` rows reach this; the cross-
2562/// platform `WhisperCpp` STT entry, Windows OS TTS and the remote ElevenLabs
2563/// rows are filtered out by the caller's `is_mlx()` guard and keep their own
2564/// availability.
2565// Both call sites sit inside `cfg(macos + aarch64 + not(car_skip_mlx))` blocks;
2566// the else-arm marks every MLX row unavailable without asking, because there is
2567// no MLX there to ask about. So this is genuinely dead off Apple Silicon.
2568#[allow(dead_code)]
2569fn speech_mlx_available() -> bool {
2570    // Via `venv_program`, so this agrees with `SpeechRuntime` about where a
2571    // provisioned runtime keeps its console scripts — `Scripts\*.exe` on
2572    // Windows, `bin/*` elsewhere.
2573    let runtime_root = speech_runtime_root();
2574    crate::managed_venv::venv_program(&runtime_root, "mlx_audio.stt.generate").exists()
2575        || crate::managed_venv::venv_program(&runtime_root, "mlx_audio.tts.generate").exists()
2576}
2577
2578// Dead off Apple Silicon for the same reason as its only caller above.
2579#[allow(dead_code)]
2580fn speech_runtime_root() -> PathBuf {
2581    if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
2582        if !path.trim().is_empty() {
2583            return PathBuf::from(path);
2584        }
2585    }
2586    std::env::var_os("HOME")
2587        .or_else(|| std::env::var_os("USERPROFILE"))
2588        .map(PathBuf::from)
2589        .unwrap_or_else(|| PathBuf::from("."))
2590        .join(".car")
2591        .join("speech-runtime")
2592}
2593
2594/// Backward-compatible ModelInfo for listing (used by CLI and old callers).
2595#[derive(Debug, Clone, Serialize, Deserialize)]
2596pub struct ModelInfo {
2597    pub id: String,
2598    pub name: String,
2599    pub provider: String,
2600    pub capabilities: Vec<ModelCapability>,
2601    pub param_count: String,
2602    pub size_mb: u64,
2603    pub context_length: usize,
2604    pub available: bool,
2605    pub is_local: bool,
2606    /// True only for a raw `VllmMlx` source whose runtime is operated outside
2607    /// CAR. The endpoint may be loopback or remote; its address is not
2608    /// ownership evidence. CAR-owned `ManagedVllmMlx` remains a local row.
2609    #[serde(default)]
2610    pub operator_managed_external_runtime: bool,
2611    /// Weights resolvable on disk *now* — i.e. usable without a download.
2612    /// Distinct from `available`, which for a lazy-downloading MLX entry is
2613    /// true before a byte is fetched (#164). Remote models need no weights
2614    /// and report `true`. See `ModelSchema::weights_ready` (#638).
2615    ///
2616    /// `#[serde(default)]` so a client built against this version can still
2617    /// parse a catalog from a daemon that predates the field.
2618    #[serde(default)]
2619    pub weights_ready: bool,
2620    /// Whether CAR fetches weights to disk for this entry at all — i.e.
2621    /// whether `weights_ready` is a question with a meaningful answer.
2622    ///
2623    /// `false` for OS-provided models (`windows/speech-synthesis:os`,
2624    /// `apple/foundation:default`), for server-backed local models
2625    /// (`vllm-mlx/*`, Ollama) and for every remote entry. `is_local` is NOT a
2626    /// substitute: the first four of those are local and still download
2627    /// nothing. Consumers should render no install status when this is
2628    /// `false`. See `ModelSchema::downloads_weights` (#894).
2629    ///
2630    /// `#[serde(default)]` so a client built against this version can still
2631    /// parse a catalog from a daemon that predates the field.
2632    #[serde(default)]
2633    pub downloads_weights: bool,
2634    /// Per-model maximum OUTPUT tokens (registry-declared). `None` when the
2635    /// catalog entry omits it; callers fall back to a fraction of
2636    /// `context_length` via `ModelSchema::effective_max_output()`.
2637    #[serde(default)]
2638    pub max_output_tokens: Option<usize>,
2639    /// Public benchmark scores carried straight through from `ModelSchema`.
2640    /// The built-in catalog ships this empty; populating it is a curation
2641    /// step (see `BenchmarkScore` in the schema for shape and conventions).
2642    #[serde(default)]
2643    pub public_benchmarks: Vec<crate::schema::BenchmarkScore>,
2644    /// Declared per-token prices carried straight through from
2645    /// `ModelSchema::cost` — USD per 1M tokens for uncached input, output,
2646    /// cache reads and cache writes, plus any prompt-size pricing tiers the
2647    /// catalog declares.
2648    ///
2649    /// Every price component is `Option`: a local model that declares no
2650    /// prices reports them as `null`, which is deliberately **not** the same
2651    /// as a declared `0.0`. Consumers that price a request must distinguish
2652    /// "free" from "unpriced" — collapsing the two is how a caller ends up
2653    /// publishing a fabricated $0.00.
2654    ///
2655    /// `#[serde(default)]` so a newly built client can parse a catalog from
2656    /// an older daemon that predates this field: the row deserializes with an
2657    /// all-`None` cost rather than failing the whole response.
2658    #[serde(default)]
2659    pub cost: crate::schema::CostModel,
2660    #[serde(default = "default_true")]
2661    pub car_enabled: bool,
2662    #[serde(default)]
2663    pub can_remove: bool,
2664    #[serde(default)]
2665    pub in_use: bool,
2666    #[serde(default)]
2667    pub management_evidence: Option<String>,
2668    /// Whether this row fits the machine that answered, judged by the same
2669    /// rule `models.recommend` partitions on: the cold-load estimate at the
2670    /// recommendation context against the active resource policy's ceiling.
2671    /// `fits` for remote, delegated and operator-managed rows, whose memory is
2672    /// not this machine's. Computed per request from `HardwareInfo::detect()`
2673    /// and the engine's active policy; never persisted.
2674    ///
2675    /// `#[serde(default)]` (→ `unknown`) so a client built against this
2676    /// version can parse a catalog from a daemon that predates the field, and
2677    /// so an absent verdict never reads as too-big.
2678    #[serde(default)]
2679    pub fit: crate::recommend::ModelFitStatus,
2680    /// The cold-load peak `fit` was judged against, in MB. `null` when the
2681    /// row's memory is not this machine's or it declares no size/RAM figure.
2682    #[serde(default)]
2683    pub estimated_peak_mb: Option<u64>,
2684    /// Whether this machine's accelerator can run the row at all — `false`
2685    /// for a Metal-only row (MLX, managed vLLM-MLX, Apple FoundationModels)
2686    /// on a non-Apple machine, for a GGUF/Candle row on Apple Silicon (CAR
2687    /// runs local text there only through MLX), for WindowsSpeech or a
2688    /// `windows-only` row off Windows, and for a `linux-only` row off Linux:
2689    /// the rows `models.recommend` never offers. See
2690    /// [`crate::recommend::platform_compatible`].
2691    /// Defaults to `true` for the same older-daemon reason as `fit`.
2692    #[serde(default = "default_true")]
2693    pub platform_compatible: bool,
2694    /// Carried through from `ModelSchema::deprecated`: superseded in the
2695    /// catalog. The row stays listed (an installed deprecated model is still
2696    /// usable); clients lead with current models by sorting or hiding on it.
2697    #[serde(default)]
2698    pub deprecated: bool,
2699    /// The credential this row is waiting on, when a missing credential is the
2700    /// ONLY reason it is unavailable.
2701    ///
2702    /// `Some(env_name)` means: this is a remote row, it is not available, and
2703    /// the single thing standing between the user and using it is that named
2704    /// credential. `None` means everything else — the row is available, or it
2705    /// is local, or it is unavailable for a reason a credential will not fix
2706    /// (deprecated, a gateway with no upstream, the managed
2707    /// `parslee/openrouter/*` rows whose availability is a session-plus-upstream
2708    /// question rather than a key).
2709    ///
2710    /// **The daemon answers this so the client never has to guess it.** The
2711    /// obvious client-side shortcut — "a remote row with `available: false`
2712    /// probably needs a key" — is wrong for every case in the previous
2713    /// paragraph, and the protocol already tells clients not to reconstruct
2714    /// state from `available` alone. The alternative, a provider-to-variable
2715    /// map in the UI, duplicates the catalog that owns `api_key_env`.
2716    ///
2717    /// Naming the variable is not a disclosure: `ANTHROPIC_API_KEY` is the
2718    /// public name of an environment variable, carries no value, and is
2719    /// already printed by `car models list` and every provider's own docs.
2720    ///
2721    /// **Only ever populated on an explicit status read**, never on a passive
2722    /// one. A passive refresh resolves credentials from the process
2723    /// environment alone, so a key held in the OS secret store reads as absent
2724    /// — and a row naming a credential the user already has, telling them to
2725    /// add it, is the one piece of advice worse than none. The field's
2726    /// contract is "a missing credential is the SOLE reason this row is
2727    /// unavailable"; a passive refresh cannot establish that, so it does not
2728    /// claim it. `available` is left alone and remains passive-accurate on
2729    /// that path.
2730    ///
2731    /// Serialized as `null` rather than omitted, matching every other optional
2732    /// field on this row (`management_evidence`, `estimated_peak_mb`). The row
2733    /// has a fixed key set, and `tests/models_surface_gate.rs` asserts it
2734    /// exactly — a conditionally-present key would make that gate
2735    /// unmaintainable and hand clients a shape that changes per row.
2736    #[serde(default)]
2737    pub credential_required: Option<String>,
2738    /// Actionable provider setup guidance; absent when no specific remedy is known.
2739    #[serde(default)]
2740    pub unavailable_reason: Option<String>,
2741    /// Model line for grouping (`qwen3`, `gemma-4`), published for local rows
2742    /// only. Remote rows report `null` because this response deliberately
2743    /// carries no upstream identifier for the managed `parslee/openrouter/*`
2744    /// aliases, whose `family` names the upstream model; `models.search`
2745    /// publishes it for every row.
2746    #[serde(default)]
2747    pub family: Option<String>,
2748    /// Version or checkpoint label within the family, local rows only for
2749    /// the same reason as `family` (an alias's `version` is the upstream
2750    /// snapshot).
2751    #[serde(default)]
2752    pub version: Option<String>,
2753    /// The row's source kind, `ModelSource`'s `type` tag (`codex_cli`,
2754    /// `remote_api`, `proprietary`, ...). Clients use it to keep two routes
2755    /// to one model apart, for example a ChatGPT-subscription row and an
2756    /// API-key row with the same capabilities. Names no upstream model.
2757    /// Empty from a daemon that predates the field.
2758    #[serde(default)]
2759    pub source_kind: String,
2760}
2761
2762/// Explain why a listed model is unavailable when CAR has one actionable,
2763/// provider-specific answer.
2764pub fn model_unavailable_reason(info: &ModelInfo) -> Option<&'static str> {
2765    (!info.available && info.provider.eq_ignore_ascii_case(OPENAI_CODEX_PROVIDER))
2766        .then_some(crate::schema::OPENAI_CODEX_SIGN_IN_HINT)
2767}
2768
2769fn default_true() -> bool {
2770    true
2771}
2772
2773impl ModelInfo {
2774    /// Stamp a computed fit annotation onto the row. Kept as one call so no
2775    /// caller can publish `fit` without the estimate and platform flag it
2776    /// was judged with.
2777    pub fn with_fit(mut self, fit: crate::recommend::ModelFit) -> Self {
2778        self.fit = fit.fit;
2779        self.estimated_peak_mb = fit.estimated_peak_mb;
2780        self.platform_compatible = fit.platform_compatible;
2781        self
2782    }
2783}
2784
2785impl From<&ModelSchema> for ModelInfo {
2786    fn from(s: &ModelSchema) -> Self {
2787        let mut info = ModelInfo {
2788            id: s.id.clone(),
2789            name: s.name.clone(),
2790            provider: s.provider.clone(),
2791            capabilities: s.capabilities.clone(),
2792            param_count: s.param_count.clone(),
2793            size_mb: s.size_mb(),
2794            context_length: s.context_length,
2795            available: s.available_now(),
2796            is_local: s.is_local(),
2797            operator_managed_external_runtime: matches!(s.source, ModelSource::VllmMlx { .. }),
2798            weights_ready: s.weights_ready,
2799            downloads_weights: s.downloads_weights(),
2800            max_output_tokens: s.max_output_tokens,
2801            public_benchmarks: s.public_benchmarks.clone(),
2802            // Prices only — the managed `parslee/…` aliases carry the same
2803            // `CostModel` as the personal row they front, and a `CostModel`
2804            // holds no identifiers, so publishing it discloses nothing about
2805            // the upstream model id.
2806            cost: s.cost.clone(),
2807            car_enabled: true,
2808            can_remove: false,
2809            in_use: false,
2810            management_evidence: None,
2811            // Not a verdict: the machine and policy are the engine's to
2812            // supply. `list_models_unified` stamps the real annotation with
2813            // `with_fit`; an unannotated projection reads as "unknown", the
2814            // value that hides nothing.
2815            fit: crate::recommend::ModelFitStatus::Unknown,
2816            estimated_peak_mb: None,
2817            platform_compatible: true,
2818            credential_required: s.credential_required(),
2819            unavailable_reason: None,
2820            deprecated: s.deprecated,
2821            // Local rows only — see the field docs for the non-disclosure
2822            // rule on managed aliases.
2823            family: s.is_local().then(|| s.family.clone()),
2824            version: s.is_local().then(|| s.version.clone()),
2825            source_kind: s.source.kind().to_string(),
2826        };
2827        info.unavailable_reason = model_unavailable_reason(&info).map(str::to_owned);
2828        info
2829    }
2830}
2831
2832/// Emit a lightweight `FileStarted` marker for an MLX auxiliary/weight file.
2833/// MLX pulls don't know `total_files` up front (single vs sharded), so these
2834/// carry `total_files = 0` ("unknown") and the UI shows the file name. The
2835/// sharded weight loop, which knows its count, pairs Started/Completed itself.
2836fn emit_file(sink: &ProgressSink, name: &str, index: u32, size_mb: u64) {
2837    sink.emit(DownloadEvent::FileStarted {
2838        filename: name.to_string(),
2839        index,
2840        total_files: 0,
2841        size_mb,
2842    });
2843}
2844
2845/// Download a single file from a HuggingFace repo.
2846/// Mirror every file in a HuggingFace repo into `model_dir`, preserving its
2847/// subdirectory structure. For diffusers-layout models (Flux/LTX) whose weights
2848/// live in component subdirs (`transformer/`, `vae/`, `text_encoder/`, …) with
2849/// no root `config.json` or top-level weight index — the file-by-file MLX flow
2850/// can't enumerate them, so the whole snapshot is fetched. The repo file list
2851/// comes from the HF models API (version-independent, unlike hf-hub's `info()`);
2852/// metadata (`.gitattributes`, dotfiles, markdown) is skipped.
2853async fn download_repo_snapshot(
2854    repo: &str,
2855    model_dir: &Path,
2856    sink: &ProgressSink,
2857) -> Result<(), InferenceError> {
2858    #[derive(serde::Deserialize)]
2859    struct RepoInfo {
2860        siblings: Vec<Sibling>,
2861    }
2862    #[derive(serde::Deserialize)]
2863    struct Sibling {
2864        rfilename: String,
2865    }
2866    let url = format!("https://huggingface.co/api/models/{repo}");
2867    // Not `Client::new()`: that is `build().expect(..)`, which panics when the
2868    // OS trust store loads zero valid certificates — the failure the ladder in
2869    // `tls_client` exists to survive. huggingface.co is a public endpoint, so
2870    // the public-CA rung serves this call fully.
2871    let info: RepoInfo = crate::tls_client::model_download_client()
2872        .get(&url)
2873        .send()
2874        .await
2875        .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2876        .error_for_status()
2877        .map_err(|e| InferenceError::DownloadFailed(format!("list {repo}: {e}")))?
2878        .json()
2879        .await
2880        .map_err(|e| InferenceError::DownloadFailed(format!("parse {repo} file list: {e}")))?;
2881
2882    let files: Vec<String> = info
2883        .siblings
2884        .into_iter()
2885        .map(|s| s.rfilename)
2886        .filter(|f| !f.starts_with('.') && !f.to_ascii_lowercase().ends_with(".md"))
2887        .collect();
2888    if files.is_empty() {
2889        return Err(InferenceError::DownloadFailed(format!(
2890            "{repo}: repo lists no downloadable files"
2891        )));
2892    }
2893
2894    let total = files.len() as u32;
2895    for (i, fname) in files.iter().enumerate() {
2896        let dest = model_dir.join(fname);
2897        if crate::download::cache_file_usable(&dest) {
2898            continue;
2899        }
2900        if let Some(parent) = dest.parent() {
2901            std::fs::create_dir_all(parent)?;
2902        }
2903        sink.emit(DownloadEvent::FileStarted {
2904            filename: fname.clone(),
2905            index: (i + 1) as u32,
2906            total_files: total,
2907            size_mb: 0,
2908        });
2909        download_file(repo, fname, &dest).await?;
2910        sink.emit(DownloadEvent::FileCompleted {
2911            filename: fname.clone(),
2912        });
2913    }
2914    Ok(())
2915}
2916
2917/// Tokenizer filenames seen across the repos CAR pulls, in preference order.
2918///
2919/// There is no single convention, which is the whole point of this list:
2920///
2921/// | Layout | Files | Example |
2922/// |---|---|---|
2923/// | `tokenizers` library | `tokenizer.json` | most modern text MLX repos |
2924/// | GPT-2 BPE pair | `vocab.json`, `merges.txt` | `Qwen3-TTS-12Hz-1.7B-Base-5bit` |
2925/// | SentencePiece | `tokenizer.model`, `tokenizer.vocab`, `vocab.txt` | `parakeet-tdt-0.6b-v3` |
2926/// | none at all | — | `Kokoro-82M-*` (phoneme-based, needs no tokenizer) |
2927const TOKENIZER_FILENAMES: &[&str] = &[
2928    "tokenizer.json",
2929    "vocab.json",
2930    "merges.txt",
2931    "tokenizer.model",
2932    "tokenizer.vocab",
2933    "vocab.txt",
2934];
2935
2936/// Fetch whatever tokenizer assets a repo actually has. **Never fails the pull
2937/// over their absence.**
2938///
2939/// This used to be a single `download_file(repo, "tokenizer.json", …).await?` —
2940/// an unconditional hard requirement that aborted the entire multi-gigabyte
2941/// acquisition when the file 404'd. That made every curated speech model
2942/// impossible to install, in either direction: both Kokoro TTS builds, the
2943/// Qwen3-TTS default, and the Parakeet STT default, so local speech did not work
2944/// at all on a clean machine (Parslee-ai/car#639).
2945///
2946/// Best-effort is the correct posture here, not a workaround. The downloader
2947/// cannot know what a given backend needs — and Kokoro settles it: it ships
2948/// **no** tokenizer files whatsoever, because a phoneme-based TTS has no use for
2949/// one. Any rule of the form "a tokenizer must be present" is therefore wrong
2950/// for some model CAR legitimately supports. A loader that genuinely needs a
2951/// tokenizer will fail at load with a precise, model-specific error, which beats
2952/// a blanket download-time abort that names a file the model was never going to
2953/// have. This also matches the treatment `tokenizer_config.json` already gets
2954/// on the very next line.
2955async fn download_tokenizer_assets(hf_repo: &str, model_dir: &Path, sink: &ProgressSink) {
2956    // Already satisfied by any recognized layout? Then don't re-probe the API.
2957    if TOKENIZER_FILENAMES
2958        .iter()
2959        .any(|f| crate::download::cache_file_usable(&model_dir.join(f)))
2960    {
2961        return;
2962    }
2963    emit_file(sink, "tokenizer", 0, 0);
2964    let mut fetched: Vec<&str> = Vec::new();
2965    for name in TOKENIZER_FILENAMES {
2966        let dest = model_dir.join(name);
2967        if crate::download::cache_file_usable(&dest) {
2968            continue;
2969        }
2970        if download_file(hf_repo, name, &dest).await.is_ok() {
2971            fetched.push(name);
2972        }
2973    }
2974    if fetched.is_empty() {
2975        // Not an error: see the doc comment — Kokoro has none by design.
2976        tracing::debug!(
2977            repo = %hf_repo,
2978            "no tokenizer assets in this repo; continuing (the backend may not need one)"
2979        );
2980    } else {
2981        tracing::debug!(repo = %hf_repo, files = ?fetched, "fetched tokenizer assets");
2982    }
2983}
2984
2985async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
2986    // Do not even consult the remote/cache when a prior install is usable.
2987    // Conversely, bare existence is not enough: zero-byte files and dangling
2988    // symlinks must be repaired rather than accepted forever.
2989    if crate::download::cache_file_usable(dest) {
2990        return Ok(());
2991    }
2992
2993    let api = crate::hf_cache::api()
2994        .build()
2995        .map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
2996
2997    // hf-hub's `get` returns an already-cached file without fetching; only a
2998    // file the cache did not hold is one CAR downloaded.
2999    let already_cached = hf_hub::Cache::new(crate::hf_cache::hub_dir())
3000        .repo(hf_hub::Repo::model(repo.to_string()))
3001        .get(filename)
3002        .is_some();
3003    let repo_id = repo;
3004    let repo = api.model(repo.to_string());
3005    let path = repo
3006        .get(filename)
3007        .await
3008        .map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
3009    if !already_cached {
3010        crate::retire::note_fetched(repo_id, filename, crate::retire::snapshot_revision(&path));
3011    }
3012
3013    install_fetched_file(&path, dest)
3014}
3015
3016/// Install an already-fetched cache file without consulting the network.
3017fn install_fetched_file(src: &Path, dest: &Path) -> Result<(), InferenceError> {
3018    if crate::download::cache_file_usable(dest) {
3019        return Ok(());
3020    }
3021
3022    match std::fs::symlink_metadata(dest) {
3023        Ok(_) => std::fs::remove_file(dest).map_err(|e| {
3024            InferenceError::DownloadFailed(format!(
3025                "remove unusable destination {}: {e}",
3026                dest.display()
3027            ))
3028        })?,
3029        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3030        Err(error) => {
3031            return Err(InferenceError::DownloadFailed(format!(
3032                "inspect destination {}: {error}",
3033                dest.display()
3034            )));
3035        }
3036    }
3037
3038    // Symlink creation publishes one complete directory entry. If the
3039    // platform/filesystem does not support it, copy beside the destination and
3040    // rename only after the complete source bytes are present.
3041    #[cfg(unix)]
3042    {
3043        if std::os::unix::fs::symlink(src, dest).is_ok() {
3044            return Ok(());
3045        }
3046    }
3047
3048    static INSTALL_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3049    let sequence = INSTALL_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3050    let file_name = dest
3051        .file_name()
3052        .and_then(|name| name.to_str())
3053        .unwrap_or("download");
3054    let temp = dest.with_file_name(format!(
3055        ".{file_name}.car-install-{}-{sequence}.tmp",
3056        std::process::id()
3057    ));
3058    let _ = std::fs::remove_file(&temp);
3059    std::fs::copy(src, &temp).map_err(|error| {
3060        InferenceError::DownloadFailed(format!(
3061            "copy to temporary destination {}: {error}",
3062            temp.display()
3063        ))
3064    })?;
3065    if let Err(error) = std::fs::rename(&temp, dest) {
3066        let _ = std::fs::remove_file(&temp);
3067        return Err(InferenceError::DownloadFailed(format!(
3068            "publish downloaded file at {}: {error}",
3069            dest.display()
3070        )));
3071    }
3072    Ok(())
3073}
3074
3075/// The Flux checkpoint whose T5 tokenizer the MLX Flux row fetches from its base
3076/// model: `(mlx repo, mlx model name, base repo, file)`. One definition for the
3077/// downloader here and for [`crate::retire`]'s ownership, which must know that
3078/// CAR takes one file from the base repo and owns nothing else in it.
3079pub(crate) const FLUX_AUXILIARY: (&str, &str, &str, &str) = (
3080    "mlx-community/Flux-1.lite-8B-MLX-Q4",
3081    "Flux-1.lite-8B-MLX-Q4",
3082    "Freepik/flux.1-lite-8B",
3083    "tokenizer_2/tokenizer.json",
3084);
3085
3086/// The text encoder every LTX video generation loads. CAR passes it to the
3087/// `ltx-2-mlx` runtime explicitly rather than inheriting the runtime's default,
3088/// so what CAR's video rows fetch is stated here — for the runtime, and for
3089/// [`crate::retire`], which would otherwise see an 8 GB repo nothing references
3090/// and report it as the user's.
3091pub(crate) const LTX_TEXT_ENCODER: &str = "mlx-community/gemma-3-12b-it-4bit";
3092
3093/// Whether `schema` generates video through the `ltx-2-mlx` runtime — every
3094/// MLX video row does.
3095pub(crate) fn uses_ltx_text_encoder(schema: &ModelSchema) -> bool {
3096    matches!(schema.source, ModelSource::Mlx { .. })
3097        && schema.has_capability(ModelCapability::VideoGeneration)
3098}
3099
3100pub(crate) fn uses_flux_auxiliary(model_name: &str, hf_repo: &str) -> bool {
3101    hf_repo == FLUX_AUXILIARY.0 || model_name == FLUX_AUXILIARY.1
3102}
3103
3104fn auxiliary_mlx_files_missing(model_name: &str, hf_repo: &str, model_dir: &Path) -> bool {
3105    uses_flux_auxiliary(model_name, hf_repo)
3106        && !crate::download::cache_file_usable(
3107            &model_dir.join("tokenizer_2").join("tokenizer.json"),
3108        )
3109}
3110
3111async fn ensure_auxiliary_mlx_files(
3112    model_name: &str,
3113    hf_repo: &str,
3114    model_dir: &Path,
3115) -> Result<(), InferenceError> {
3116    if auxiliary_mlx_files_missing(model_name, hf_repo, model_dir) {
3117        let t5_tokenizer_path = model_dir.join("tokenizer_2").join("tokenizer.json");
3118        std::fs::create_dir_all(
3119            t5_tokenizer_path
3120                .parent()
3121                .ok_or_else(|| InferenceError::InferenceFailed("invalid tokenizer path".into()))?,
3122        )?;
3123        info!(
3124            path = %t5_tokenizer_path.display(),
3125            "downloading missing Flux tokenizer_2/tokenizer.json from base model"
3126        );
3127        download_file(FLUX_AUXILIARY.2, FLUX_AUXILIARY.3, &t5_tokenizer_path).await?;
3128    }
3129    Ok(())
3130}
3131
3132fn mlx_auxiliary_ready_without_download(model_name: &str, model_dir: &Path) -> bool {
3133    if model_name == "Flux-1.lite-8B-MLX-Q4" {
3134        return crate::download::cache_file_usable(
3135            &model_dir.join("tokenizer_2").join("tokenizer.json"),
3136        );
3137    }
3138    true
3139}
3140
3141/// The single physical-readiness projection used by registration, refresh,
3142/// recommendation/setup state, and ready-without-download checks.
3143fn physical_weights_ready(schema: &ModelSchema, models_dir: &Path) -> bool {
3144    physical_weights_ready_with_huggingface_hub(schema, models_dir, None)
3145}
3146
3147pub(crate) fn physical_weights_ready_with_huggingface_hub(
3148    schema: &ModelSchema,
3149    models_dir: &Path,
3150    huggingface_hub_root: Option<&Path>,
3151) -> bool {
3152    match &schema.source {
3153        ModelSource::Mlx { hf_repo, .. } | ModelSource::ManagedVllmMlx { hf_repo, .. } => {
3154            let managed_dir = models_dir.join(&schema.name);
3155            if mlx_snapshot_complete(schema, &managed_dir) {
3156                return true;
3157            }
3158            let shared_snapshot = match huggingface_hub_root {
3159                Some(root) => latest_huggingface_repo_snapshot_in(&crate::hf_cache::repo_dir_in(
3160                    root, hf_repo,
3161                )),
3162                None => latest_huggingface_repo_snapshot(hf_repo),
3163            };
3164            shared_snapshot
3165                .as_deref()
3166                .is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3167        }
3168        ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
3169        ModelSource::Local { .. } => {
3170            crate::download::cache_file_usable(&models_dir.join(&schema.name).join("model.gguf"))
3171        }
3172        // These sources have no CAR-downloaded artifact. `weights_ready` is a
3173        // readiness sentinel for legacy routing; callers must gate install
3174        // language with `downloads_weights()`.
3175        ModelSource::WindowsSpeech {}
3176        | ModelSource::AppleFoundationModels { .. }
3177        | ModelSource::VllmMlx { .. }
3178        | ModelSource::Ollama { .. }
3179        | ModelSource::RemoteApi { .. }
3180        | ModelSource::CodexCli { .. }
3181        | ModelSource::Proprietary { .. }
3182        | ModelSource::Delegated { .. } => true,
3183    }
3184}
3185
3186#[cfg(test)]
3187fn mlx_weights_ready_at(
3188    schema: &ModelSchema,
3189    managed_dir: &Path,
3190    shared_snapshot: Option<&Path>,
3191) -> bool {
3192    mlx_snapshot_complete(schema, managed_dir)
3193        || shared_snapshot.is_some_and(|snapshot| mlx_snapshot_complete(schema, snapshot))
3194}
3195
3196/// A complete MLX location, whether CAR's managed directory or a shared
3197/// Hugging Face snapshot. Standard text models need config + all indexed
3198/// shards. Diffusers layouts have no root config, so their recursively
3199/// validated component weights are the completeness authority. Tokenizer
3200/// readiness is a stricter invocation concern handled by
3201/// `ready_without_download`; `weights_ready` remains literal physical weights.
3202fn mlx_snapshot_complete(schema: &ModelSchema, dir: &Path) -> bool {
3203    mlx_layout_complete(mlx_schema_is_diffusers(schema), dir)
3204        && mlx_auxiliary_ready_without_download(&schema.name, dir)
3205}
3206
3207/// Whether a row stores its weights in the diffusers component layout (Flux
3208/// image generation, LTX video): component subdirectories with no root
3209/// `config.json` and no top-level weight index.
3210fn mlx_schema_is_diffusers(schema: &ModelSchema) -> bool {
3211    schema.capabilities.iter().any(|capability| {
3212        matches!(
3213            capability,
3214            ModelCapability::ImageGeneration | ModelCapability::VideoGeneration
3215        )
3216    })
3217}
3218
3219/// Root metadata plus weights: the one layout test shared by every reuse
3220/// gate and by the installed-state projection, so the two can never disagree
3221/// about the same directory. A standard MLX directory needs a `config.json`
3222/// that resolves and is non-empty (`cache_file_usable`, the bar each
3223/// per-file download applies) and every weight file present; a zero-byte or
3224/// dangling config is therefore re-fetched rather than reused. Diffusers
3225/// layouts have no root config, so their recursively validated component
3226/// weights are the whole signal.
3227fn mlx_layout_complete(is_diffusers: bool, dir: &Path) -> bool {
3228    (is_diffusers || crate::download::cache_file_usable(&dir.join("config.json")))
3229        && mlx_dir_has_weights(dir)
3230}
3231
3232/// Whether a `ModelSource::Mlx` row is executed by the **in-process** Swift
3233/// loader, as opposed to one of the subprocesses that also read MLX weights.
3234///
3235/// `ModelSource::Mlx` is a statement about the weight format, not about who
3236/// runs it. Five executors share it today: the in-process text loader, the
3237/// managed `mlx-audio` venv (speech), `mflux` (image), `ltx-2-mlx` (video) and
3238/// the `mlx_vlm` Python CLI. Only the first needs the Swift stack linked into
3239/// this binary, and gating the other four on it marked working subprocess
3240/// models unavailable — `car image` and `car video` lose their only local
3241/// backend on a machine whose Swift build merely failed.
3242///
3243/// Both availability paths call this, because they are required to agree: a
3244/// model whose `available` flips depending on whether registration or refresh
3245/// ran last is worse than either answer.
3246// Both call sites sit inside `cfg(macos + aarch64 + not(car_skip_mlx))`; the
3247// else-arm marks every MLX row unavailable without asking, because there is no
3248// MLX there to ask about. So this is genuinely dead off Apple Silicon — as the
3249// `cargo check --cfg=car_skip_mlx` gate in ci-local.sh reported the moment it
3250// was added.
3251#[allow(dead_code)]
3252fn mlx_row_needs_in_process_loader(schema: &ModelSchema) -> bool {
3253    use crate::schema::ModelCapability as C;
3254    let tagged = |t: &str| schema.tags.iter().any(|x| x == t);
3255    if tagged("speech") || tagged("requires-mlx-vlm") {
3256        return false;
3257    }
3258    if schema
3259        .capabilities
3260        .iter()
3261        .any(|c| matches!(c, C::ImageGeneration | C::VideoGeneration))
3262    {
3263        return false;
3264    }
3265    matches!(schema.source, ModelSource::Mlx { .. })
3266}
3267
3268/// Does a managed MLX model dir actually contain resolvable weights?
3269///
3270/// A dir holding only `config.json`/`tokenizer.json` stubs — or a dangling
3271/// `*.safetensors` symlink into a pruned HuggingFace cache — is NOT a complete
3272/// install. Treating it as installed makes `ensure_local`/`car models pull`
3273/// no-op and inference then fails at load with "no safetensors weights found"
3274/// (car-releases#391). We check for at least one `*.safetensors` whose target
3275/// resolves: `Path::exists()` follows symlinks, so a dangling link counts as
3276/// absent. This covers single-file (`model.safetensors`) and sharded
3277/// (`model-00001-of-0000N.safetensors`) layouts; the bare
3278/// `model.safetensors.index.json` (extension `.json`) correctly doesn't count.
3279///
3280/// **Sharded models are checked against their index, not by existence of any
3281/// one shard.** "At least one `*.safetensors` resolves" is the right question
3282/// for a single-file model and the wrong one for a sharded install: an
3283/// interrupted download that landed shard 2 of 2 satisfies it, so
3284/// `ensure_local` returns early without repairing, `car models pull` reports
3285/// success having done nothing, and the failure surfaces only at load —
3286/// `load model-00001-of-00002.safetensors: Path must point to a local file`
3287/// (Parslee-ai/car#808). The index enumerates every required shard, so when it
3288/// is present it is authoritative and cheap to consult.
3289pub(crate) fn mlx_dir_has_weights(dir: &Path) -> bool {
3290    // An index present at the top level means a sharded layout: require EVERY
3291    // shard it names. Absent (single-file, or a diffusers-layout model whose
3292    // components live in subdirs), fall back to the recursive any-weights walk.
3293    let index = dir.join("model.safetensors.index.json");
3294    if index.is_file() {
3295        return sharded_weight_files(&index).is_some_and(|required| {
3296            !required.is_empty()
3297                && required
3298                    .iter()
3299                    .all(|shard| crate::download::cache_file_usable(&dir.join(shard)))
3300        });
3301    }
3302    mlx_dir_has_weights_depth(dir, 0)
3303}
3304
3305/// Weight shards the index requires but that are not present on disk (#808).
3306///
3307/// Empty means "no shard filename could be reported" — including for
3308/// single-file/diffusers layouts and malformed indexes. Callers must pair this
3309/// with [`mlx_dir_has_weights`]; an empty diagnostic is never proof that the
3310/// model is complete.
3311///
3312/// Exists so a failed pull can say *which* shard is absent. The reported case
3313/// (#808) left a 6.7 GB model with one of two shards on disk for weeks, and the
3314/// only symptom was an inference-time `Path must point to a local file` naming
3315/// a shard the caller had no reason to know about.
3316pub(crate) fn missing_weight_shards(dir: &Path) -> Vec<String> {
3317    let index = dir.join("model.safetensors.index.json");
3318    if !index.is_file() {
3319        return Vec::new();
3320    }
3321    let Some(required) = sharded_weight_files(&index) else {
3322        // Unreadable index: there is no trustworthy shard filename to report.
3323        // Readiness still fails closed in `mlx_dir_has_weights`.
3324        return Vec::new();
3325    };
3326    required
3327        .into_iter()
3328        .filter(|shard| !dir.join(shard).exists())
3329        .collect()
3330}
3331
3332/// Shard filenames an MLX/HF `model.safetensors.index.json` says are required.
3333///
3334/// `weight_map` maps every tensor name to the file holding it, so the distinct
3335/// values are exactly the shard set. Returns `None` when the index cannot be
3336/// read or has no `weight_map`; readiness callers must fail closed when an
3337/// index exists but this metadata cannot be validated.
3338fn sharded_weight_files(index: &Path) -> Option<Vec<String>> {
3339    let raw = std::fs::read_to_string(index).ok()?;
3340    let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
3341    let map = parsed.get("weight_map")?.as_object()?;
3342    let mut files: Vec<String> = map
3343        .values()
3344        .filter_map(|v| v.as_str().map(str::to_string))
3345        .collect();
3346    files.sort();
3347    files.dedup();
3348    Some(files)
3349}
3350
3351/// Recurse into component subdirs to find weights, but bounded. Diffusers-layout
3352/// models (Flux image, LTX video, …) keep their component weights in subdirs
3353/// (`transformer/`, `vae/`, `text_encoder/`, …), not at the top level, so a flat
3354/// scan misjudges a fully-cached snapshot as weightless (the miss that sent the
3355/// image/video path into a re-download that 404s on the `config.json` these
3356/// repos don't ship). `load_all_tensors` reads those component safetensors
3357/// recursively. The walk is depth-capped and skips symlinked directories so a
3358/// symlink cycle in a cache dir can't stack-overflow us — the HF cache nests
3359/// real component dirs at most ~2 deep, with only leaf blob files symlinked
3360/// (those are files, resolved by `cache_file_usable`, not followed here).
3361fn mlx_dir_has_weights_depth(dir: &Path, depth: usize) -> bool {
3362    if depth > 4 {
3363        return false;
3364    }
3365    let Ok(rd) = std::fs::read_dir(dir) else {
3366        return false;
3367    };
3368    rd.flatten().any(|e| {
3369        let p = e.path();
3370        let is_symlink = std::fs::symlink_metadata(&p)
3371            .map(|m| m.file_type().is_symlink())
3372            .unwrap_or(true);
3373        if p.is_dir() {
3374            !is_symlink && mlx_dir_has_weights_depth(&p, depth + 1)
3375        } else {
3376            // `cache_file_usable` follows the symlink and requires non-empty, so
3377            // a dangling weight symlink (pruned blob) or a zero-length partial
3378            // write does not count as installed.
3379            p.extension().and_then(|x| x.to_str()) == Some("safetensors")
3380                && crate::download::cache_file_usable(&p)
3381        }
3382    })
3383}
3384
3385#[allow(dead_code)] // conditionally compiled — used only on MLX-backend (macOS) snapshot-resolution paths
3386fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
3387    latest_huggingface_repo_snapshot(repo_id).is_some()
3388}
3389
3390fn resolve_huggingface_ref_snapshot(repo_dir: &Path, name: &str) -> Option<PathBuf> {
3391    let sha = std::fs::read_to_string(repo_dir.join("refs").join(name))
3392        .ok()?
3393        .trim()
3394        .to_string();
3395    if sha.is_empty() {
3396        return None;
3397    }
3398
3399    let snapshot = repo_dir.join("snapshots").join(sha);
3400    if snapshot_looks_ready(&snapshot) {
3401        Some(snapshot)
3402    } else {
3403        None
3404    }
3405}
3406
3407pub(crate) fn latest_huggingface_repo_snapshot(repo_id: &str) -> Option<PathBuf> {
3408    let repo_dir = crate::hf_cache::repo_dir(repo_id);
3409    latest_huggingface_repo_snapshot_in(&repo_dir)
3410}
3411
3412fn latest_huggingface_repo_snapshot_in(repo_dir: &Path) -> Option<PathBuf> {
3413    if let Some(snapshot) = resolve_huggingface_ref_snapshot(repo_dir, "main") {
3414        return Some(snapshot);
3415    }
3416
3417    let snapshots = repo_dir.join("snapshots");
3418    let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(snapshots)
3419        .ok()?
3420        .filter_map(Result::ok)
3421        .map(|e| e.path())
3422        .filter(|p| p.is_dir() && snapshot_looks_ready(p))
3423        .map(|path| {
3424            let modified = path
3425                .metadata()
3426                .and_then(|metadata| metadata.modified())
3427                .unwrap_or(SystemTime::UNIX_EPOCH);
3428            (modified, path)
3429        })
3430        .collect();
3431    candidates.sort();
3432    candidates.pop().map(|(_, path)| path)
3433}
3434
3435fn snapshot_looks_ready(path: &Path) -> bool {
3436    if path.join("config.json").exists() || path.join("model_index.json").exists() {
3437        return true;
3438    }
3439    snapshot_contains_ext(path, "safetensors")
3440}
3441
3442fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
3443    let Ok(entries) = std::fs::read_dir(root) else {
3444        return false;
3445    };
3446    entries.filter_map(Result::ok).any(|entry| {
3447        let path = entry.path();
3448        if path.is_dir() {
3449            snapshot_contains_ext(&path, ext)
3450        } else {
3451            let ext_matches = path
3452                .extension()
3453                .and_then(|value| value.to_str())
3454                .map(|value| value.eq_ignore_ascii_case(ext))
3455                .unwrap_or(false);
3456            // A matching extension only counts when the file is actually usable
3457            // — a dangling symlink into a pruned blob or a zero-length partial
3458            // must not make a snapshot look ready.
3459            ext_matches && crate::download::cache_file_usable(&path)
3460        }
3461    })
3462}
3463
3464/// Built-in catalog parsed from `builtin_catalog.json`.
3465///
3466/// Adding, removing, or editing a model is a JSON-only change — Rust
3467/// source stays put. The JSON is embedded at compile time via
3468/// `include_str!`, parsed once into a `LazyLock`, and cloned on each
3469/// call. A malformed JSON file fails the integration test
3470/// `builtin_catalog_json_parses` so the binary never ships unable
3471/// to load its own catalog.
3472const BUILTIN_CATALOG_JSON: &str = include_str!("builtin_catalog.json");
3473
3474static BUILTIN_CATALOG: std::sync::LazyLock<Vec<ModelSchema>> = std::sync::LazyLock::new(|| {
3475    serde_json::from_str(BUILTIN_CATALOG_JSON)
3476        .expect("builtin_catalog.json failed to parse — fix the JSON, not this code")
3477});
3478
3479pub(crate) fn builtin_catalog() -> Vec<ModelSchema> {
3480    let mut catalog = BUILTIN_CATALOG.clone();
3481    catalog.extend(crate::openrouter::builtin_schemas());
3482    catalog
3483}
3484
3485/// Credential environment names declared by CAR's built-in model catalog.
3486///
3487/// This is a value-free catalog projection for child-process scrubbing. It
3488/// does not construct a live registry, probe credentials, read caches, or
3489/// perform discovery I/O.
3490pub fn builtin_credential_env_names() -> std::collections::BTreeSet<String> {
3491    let mut names = std::collections::BTreeSet::new();
3492    for model in builtin_catalog() {
3493        match model.source {
3494            ModelSource::RemoteApi {
3495                api_key_env,
3496                api_key_envs,
3497                ..
3498            } => {
3499                names.insert(api_key_env);
3500                names.extend(api_key_envs);
3501            }
3502            ModelSource::Proprietary { auth, .. } => match auth {
3503                ProprietaryAuth::ApiKeyEnv { env_var }
3504                | ProprietaryAuth::BearerTokenEnv { env_var } => {
3505                    names.insert(env_var);
3506                }
3507                ProprietaryAuth::OAuth2Pkce { .. } | ProprietaryAuth::ChatGptSubscription {} => {}
3508            },
3509            ModelSource::Local { .. }
3510            | ModelSource::CodexCli { .. }
3511            | ModelSource::Ollama { .. }
3512            | ModelSource::Mlx { .. }
3513            | ModelSource::WhisperCpp { .. }
3514            | ModelSource::WindowsSpeech {}
3515            | ModelSource::VllmMlx { .. }
3516            | ModelSource::ManagedVllmMlx { .. }
3517            | ModelSource::AppleFoundationModels { .. }
3518            | ModelSource::Delegated { .. } => {}
3519        }
3520    }
3521    names
3522}
3523
3524/// Report whether a built-in model is paid for by a ChatGPT subscription.
3525///
3526/// Some callers hold only the durable model id, not a [`UnifiedRegistry`]. In
3527/// particular, the coder A/B harness reads a completed session after the
3528/// registry that ran it may be gone. Giving those callers a narrow catalog
3529/// lookup lets them render `subscription` instead of inventing a dollar cost,
3530/// without starting a registry merely to recover billing provenance.
3531pub fn is_builtin_subscription_billed(model_id: &str) -> bool {
3532    let latest_id = (!model_id.contains(':')).then(|| format!("{model_id}:latest"));
3533    builtin_catalog().into_iter().any(|model| {
3534        (model.id == model_id || latest_id.as_deref() == Some(model.id.as_str()))
3535            && model.is_subscription_billed()
3536    })
3537}
3538
3539/// Test support for consumers that need real built-in rows without consulting
3540/// the developer machine's shared Hugging Face cache.
3541#[doc(hidden)]
3542pub fn builtin_catalog_with_huggingface_hub_for_testing(
3543    models_dir: &Path,
3544    huggingface_hub_root: &Path,
3545) -> Vec<ModelSchema> {
3546    let mut catalog = builtin_catalog();
3547    for schema in &mut catalog {
3548        schema.weights_ready = physical_weights_ready_with_huggingface_hub(
3549            schema,
3550            models_dir,
3551            Some(huggingface_hub_root),
3552        );
3553    }
3554    catalog
3555}
3556
3557#[cfg(test)]
3558mod tests {
3559    use crate::openrouter::StateRootScope;
3560
3561    /// A sharded install missing one shard is NOT installed.
3562    ///
3563    /// The interrupted-download shape from car#808: shard 2 of 2 landed, shard
3564    /// 1 did not. The old any-weights check said "installed", so `ensure_local`
3565    /// returned early without repairing, `pull_model` reported success having
3566    /// done nothing, and the failure surfaced only at load. Synthetic dir — no
3567    /// real weights needed, which is what makes it runnable in CI.
3568    #[test]
3569    fn a_sharded_model_missing_one_shard_is_not_installed() {
3570        let tmp = tempfile::tempdir().unwrap();
3571        let dir = tmp.path();
3572        std::fs::write(
3573            dir.join("model.safetensors.index.json"),
3574            r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
3575                              "b":"model-00002-of-00002.safetensors"}}"#,
3576        )
3577        .unwrap();
3578        // Only shard 2 present — exactly the reported state.
3579        std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
3580        assert!(
3581            !mlx_dir_has_weights(dir),
3582            "a missing shard must read as not-installed, or pull silently no-ops"
3583        );
3584
3585        // Completing the set flips it.
3586        std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
3587        assert!(
3588            mlx_dir_has_weights(dir),
3589            "a complete shard set must read as installed"
3590        );
3591    }
3592
3593    /// A failed pull must be able to NAME the shard it is missing (car#808).
3594    ///
3595    /// The reported symptom was an inference-time
3596    /// `load model-00001-of-00002.safetensors: Path must point to a local file`
3597    /// — a filename the caller had no way to anticipate, surfacing inside a
3598    /// benchmarking run rather than at pull time. `missing_weight_shards` is
3599    /// what lets the pull itself say which shard is absent.
3600    #[test]
3601    fn missing_shards_are_reported_by_name() {
3602        let tmp = tempfile::tempdir().unwrap();
3603        let dir = tmp.path();
3604        std::fs::write(
3605            dir.join("model.safetensors.index.json"),
3606            r#"{"weight_map":{"a":"model-00001-of-00002.safetensors",
3607                              "b":"model-00002-of-00002.safetensors"}}"#,
3608        )
3609        .unwrap();
3610        std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"x").unwrap();
3611
3612        assert_eq!(
3613            missing_weight_shards(dir),
3614            vec!["model-00001-of-00002.safetensors".to_string()],
3615            "the absent shard must be named, not just counted"
3616        );
3617
3618        std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"x").unwrap();
3619        assert!(
3620            missing_weight_shards(dir).is_empty(),
3621            "a complete shard set must report nothing missing"
3622        );
3623    }
3624
3625    /// No index (single-file or diffusers layout) means there is no shard list
3626    /// to check against — report nothing missing rather than inventing a
3627    /// failure. `mlx_dir_has_weights` remains the completeness signal there.
3628    #[test]
3629    fn missing_shards_is_empty_without_an_index() {
3630        let tmp = tempfile::tempdir().unwrap();
3631        std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
3632        assert!(missing_weight_shards(tmp.path()).is_empty());
3633
3634        // An unreadable index is a metadata problem, not proof of a missing
3635        // shard — same call `mlx_dir_has_weights` makes, so they cannot disagree.
3636        let bad = tempfile::tempdir().unwrap();
3637        std::fs::write(bad.path().join("model.safetensors.index.json"), b"not json").unwrap();
3638        assert!(missing_weight_shards(bad.path()).is_empty());
3639    }
3640
3641    /// A single-file model has no index; the any-weights walk still governs.
3642    #[test]
3643    fn a_single_file_model_still_counts_without_an_index() {
3644        let tmp = tempfile::tempdir().unwrap();
3645        std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
3646        assert!(mlx_dir_has_weights(tmp.path()));
3647    }
3648
3649    /// A present but unreadable index is authoritative evidence we cannot
3650    /// validate, so readiness fails closed even if a stray weight file exists.
3651    #[test]
3652    fn an_unparseable_index_fails_closed() {
3653        let tmp = tempfile::tempdir().unwrap();
3654        std::fs::write(
3655            tmp.path().join("model.safetensors.index.json"),
3656            b"{not-json",
3657        )
3658        .unwrap();
3659        std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
3660        assert!(
3661            !mlx_dir_has_weights(tmp.path()),
3662            "an unreadable index must not fall back to a stray weight"
3663        );
3664    }
3665
3666    use super::*;
3667    use tempfile::TempDir;
3668
3669    #[test]
3670    fn mlx_dir_has_weights_detects_completeness() {
3671        let tmp = TempDir::new().unwrap();
3672        let dir = tmp.path();
3673
3674        // A config-only stub is NOT complete (car-releases#391).
3675        std::fs::write(dir.join("config.json"), "{}").unwrap();
3676        std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
3677        assert!(
3678            !mlx_dir_has_weights(dir),
3679            "config-only stub must not count as installed"
3680        );
3681
3682        // The bare sharded index without shards is still incomplete.
3683        std::fs::write(dir.join("model.safetensors.index.json"), "{}").unwrap();
3684        assert!(!mlx_dir_has_weights(dir), "index.json alone is not weights");
3685
3686        // A real single-file layout has no shard index.
3687        std::fs::remove_file(dir.join("model.safetensors.index.json")).unwrap();
3688        std::fs::write(dir.join("model.safetensors"), b"\x00\x01\x02").unwrap();
3689        assert!(mlx_dir_has_weights(dir));
3690    }
3691
3692    #[test]
3693    fn mlx_dir_has_weights_handles_sharded_and_dangling_symlinks() {
3694        let sharded = TempDir::new().unwrap();
3695        std::fs::write(sharded.path().join("config.json"), "{}").unwrap();
3696        std::fs::write(
3697            sharded.path().join("model-00001-of-00002.safetensors"),
3698            b"\x00",
3699        )
3700        .unwrap();
3701        assert!(mlx_dir_has_weights(sharded.path()), "sharded shard counts");
3702
3703        // A DANGLING *.safetensors symlink (into a pruned HF cache) must NOT
3704        // count — Path::exists() follows the link and returns false.
3705        #[cfg(unix)]
3706        {
3707            let dangling = TempDir::new().unwrap();
3708            std::fs::write(dangling.path().join("config.json"), "{}").unwrap();
3709            std::os::unix::fs::symlink(
3710                dangling.path().join("does-not-exist"),
3711                dangling.path().join("model.safetensors"),
3712            )
3713            .unwrap();
3714            assert!(
3715                !mlx_dir_has_weights(dangling.path()),
3716                "dangling weight symlink must count as absent"
3717            );
3718        }
3719    }
3720
3721    /// A registry over a temp state root, bundled with the two things that
3722    /// must outlive it: the temp dir, and this crate's environment lock.
3723    ///
3724    /// Holding the lock is not optional, and RETURNING it rather than binding
3725    /// it inside the helper is the whole point — a guard dropped at the end of
3726    /// `test_registry()` protects nothing. Every `UnifiedRegistry::new*` ends
3727    /// in `refresh_availability()`, which clears the process-global gateway
3728    /// observation AND `$CAR_HOME/gateway-state.json` whenever no Parslee
3729    /// token is present — exactly a CI runner's state. Two tests in this
3730    /// module (`a_gateway_that_reports_no_upstream_stops_being_advertised`,
3731    /// `a_rejected_credential_stops_the_managed_lane_being_advertised`) assert
3732    /// on that same observation, so ANY unserialized construction in this
3733    /// module can fail them. #989 locked the two direct
3734    /// `refresh_availability()` call sites but not the constructor path; this
3735    /// closes it. `cargo nextest` hides the family by giving every test its
3736    /// own process, which is why it stayed hidden until `shared-process-test`
3737    /// put a threaded `cargo test` on the per-PR path — see
3738    /// docs/solutions/process-global-state-in-tests.md.
3739    ///
3740    /// Modelled on `adaptive_router::tests::TestRegistry`, which already has
3741    /// this shape for the same reason.
3742    struct TestRegistry {
3743        registry: UnifiedRegistry,
3744        // Field drop order is intentional: tear the temp state root down
3745        // before releasing the lock, so the next test never sees a half-gone
3746        // `CAR_HOME`-shaped directory.
3747        _tmp: TempDir,
3748        _environment: tokio::sync::MutexGuard<'static, ()>,
3749    }
3750
3751    impl std::ops::Deref for TestRegistry {
3752        type Target = UnifiedRegistry;
3753
3754        fn deref(&self) -> &Self::Target {
3755            &self.registry
3756        }
3757    }
3758
3759    impl std::ops::DerefMut for TestRegistry {
3760        fn deref_mut(&mut self) -> &mut Self::Target {
3761            &mut self.registry
3762        }
3763    }
3764
3765    fn test_registry() -> TestRegistry {
3766        let _environment = crate::openrouter::test_environment_scope();
3767        let tmp = TempDir::new().unwrap();
3768        let registry = UnifiedRegistry::new_with_state_root(
3769            tmp.path().to_path_buf(),
3770            tmp.path().join("models"),
3771        );
3772        TestRegistry {
3773            registry,
3774            _tmp: tmp,
3775            _environment,
3776        }
3777    }
3778
3779    /// **`credential_required` must only ever name a credential that would
3780    /// actually fix the row.**
3781    ///
3782    /// The field exists so a client never has to infer "this needs a key"
3783    /// from `available: false`. That inference is wrong for several real
3784    /// shapes, and saying "add ANTHROPIC_API_KEY" to someone whose row is
3785    /// deprecated, OAuth-backed, or gated on a gateway upstream is worse than
3786    /// saying nothing — it sends them to do something that cannot help.
3787    /// car#1920. A secret store that ERRORS is not a store that said "absent",
3788    /// and the difference decides whether the model screen tells someone to add
3789    /// a credential they may already hold. The registry records the
3790    /// indeterminate case so the projection can stay silent; the row itself
3791    /// remains unavailable, which is the conservative direction.
3792    #[test]
3793    fn an_unreadable_credential_is_recorded_as_unknown_not_absent() {
3794        // The default registry has run no authoritative refresh, so it claims
3795        // nothing is unknown -- the value that lets `credential_required`
3796        // behave exactly as before wherever this state is not established.
3797        let registry = UnifiedRegistry::new_empty(std::path::PathBuf::from("/nonexistent"));
3798        assert!(
3799            !registry.credential_state_is_unknown("VENDOR_API_KEY"),
3800            "an unprobed registry must not claim a credential state is unknown"
3801        );
3802
3803        // And the three-way probe keeps the distinction the bool could not:
3804        // a name that cannot exist in any store is ABSENT, not unknown, so a
3805        // genuinely missing key still gets its "add this key" advice.
3806        assert_eq!(
3807            credential_presence("CAR_TEST_CREDENTIAL_THAT_DOES_NOT_EXIST"),
3808            CredentialPresence::Absent
3809        );
3810    }
3811
3812    #[test]
3813    fn credential_required_names_a_key_only_when_a_key_is_the_answer() {
3814        let remote = |available: bool, deprecated: bool| {
3815            let mut m = test_generate_schema(
3816                "vendor/m",
3817                "m",
3818                ModelSource::RemoteApi {
3819                    protocol: crate::schema::ApiProtocol::OpenAiCompat,
3820                    endpoint: "https://example.invalid/v1".into(),
3821                    api_key_env: "VENDOR_API_KEY".into(),
3822                    api_key_envs: vec![],
3823                    api_version: None,
3824                },
3825            );
3826            m.provider = "vendor".into();
3827            m.available = available;
3828            m.deprecated = deprecated;
3829            m
3830        };
3831
3832        assert_eq!(
3833            remote(false, false).credential_required().as_deref(),
3834            Some("VENDOR_API_KEY"),
3835            "an unavailable plain remote row is exactly the case this field is for"
3836        );
3837
3838        assert_eq!(
3839            remote(true, false).credential_required(),
3840            None,
3841            "an available row needs nothing"
3842        );
3843        assert_eq!(
3844            remote(false, true).credential_required(),
3845            None,
3846            "a deprecated row does not become usable by adding a key"
3847        );
3848
3849        // Several alternative variables: telling someone to set one of four
3850        // names is not actionable, and choosing for them guesses which
3851        // account they hold.
3852        let mut multi = remote(false, false);
3853        if let ModelSource::RemoteApi {
3854            ref mut api_key_envs,
3855            ..
3856        } = multi.source
3857        {
3858            *api_key_envs = vec!["VENDOR_A".into(), "VENDOR_B".into()];
3859        }
3860        assert_eq!(multi.credential_required(), None);
3861
3862        // OpenRouter resolves through a credential SOURCE (pasted key or
3863        // OAuth), not a variable a person types.
3864        let mut router = remote(false, false);
3865        router.source = ModelSource::RemoteApi {
3866            protocol: crate::schema::ApiProtocol::OpenRouter,
3867            endpoint: "https://openrouter.ai/api/v1".into(),
3868            api_key_env: "OPENROUTER_API_KEY".into(),
3869            api_key_envs: vec![],
3870            api_version: None,
3871        };
3872        assert_eq!(router.credential_required(), None);
3873
3874        // A local row has no credential at all.
3875        let mut local = test_generate_schema(
3876            "mlx/local",
3877            "local",
3878            ModelSource::Mlx {
3879                hf_repo: "org/repo".into(),
3880                hf_weight_file: None,
3881            },
3882        );
3883        local.available = false;
3884        assert_eq!(local.credential_required(), None);
3885    }
3886
3887    fn test_generate_schema(id: &str, name: &str, source: ModelSource) -> ModelSchema {
3888        ModelSchema {
3889            id: id.into(),
3890            name: name.into(),
3891            provider: "local".into(),
3892            family: "qwen3".into(),
3893            version: "test".into(),
3894            capabilities: vec![ModelCapability::Generate],
3895            context_length: 4096,
3896            max_output_tokens: None,
3897            param_count: String::new(),
3898            quantization: None,
3899            performance: PerformanceEnvelope::default(),
3900            cost: CostModel::default(),
3901            source,
3902            tags: vec![],
3903            supported_params: vec![],
3904            public_benchmarks: vec![],
3905            trust_tier: crate::schema::TrustTier::Curated,
3906            deprecated: false,
3907            available: false,
3908            weights_ready: false,
3909        }
3910    }
3911
3912    /// Parslee-ai/car#894: the schema knows whether weights are on disk, but
3913    /// the schema → `ModelInfo` projection dropped that fact, so the CLI could
3914    /// only render `available` and contradicted `car doctor`. The projection
3915    /// must carry `weights_ready` through in both directions.
3916    #[test]
3917    fn model_info_carries_weights_ready_through_the_projection() {
3918        let mut schema = test_generate_schema(
3919            "mlx-community/car894-test-4bit",
3920            "car894-test-4bit",
3921            ModelSource::Mlx {
3922                hf_repo: "mlx-community/car894-test-4bit".into(),
3923                hf_weight_file: None,
3924            },
3925        );
3926
3927        schema.weights_ready = false;
3928        assert!(
3929            !ModelInfo::from(&schema).weights_ready,
3930            "a schema with no weights on disk must project weights_ready = false"
3931        );
3932
3933        schema.weights_ready = true;
3934        assert!(
3935            ModelInfo::from(&schema).weights_ready,
3936            "a schema with weights on disk must project weights_ready = true"
3937        );
3938    }
3939
3940    /// The `weights_ready` flag is only meaningful for entries CAR actually
3941    /// downloads. `downloads_weights` is that gate, and it has to survive the
3942    /// schema → `ModelInfo` projection or the CLI is back to guessing from
3943    /// `is_local` — which is what made `windows/speech-synthesis:os` claim an
3944    /// install it does not have (#894).
3945    #[test]
3946    fn model_info_carries_downloads_weights_through_the_projection() {
3947        let mlx = test_generate_schema(
3948            "mlx-community/car894-test-4bit",
3949            "car894-test-4bit",
3950            ModelSource::Mlx {
3951                hf_repo: "mlx-community/car894-test-4bit".into(),
3952                hf_weight_file: None,
3953            },
3954        );
3955        assert!(
3956            ModelInfo::from(&mlx).downloads_weights,
3957            "an MLX entry downloads weights"
3958        );
3959
3960        // OS-owned sources are local even though CAR never downloads for
3961        // them, so a projection that reused `is_local` would get them wrong.
3962        for (label, source) in [
3963            ("windows speech", ModelSource::WindowsSpeech {}),
3964            (
3965                "apple foundation",
3966                ModelSource::AppleFoundationModels { use_case: None },
3967            ),
3968        ] {
3969            let schema = test_generate_schema("car894/os-model", "os-model", source);
3970            let info = ModelInfo::from(&schema);
3971            assert!(
3972                !info.downloads_weights,
3973                "{label} installs nothing, so the projection must say so"
3974            );
3975            assert!(
3976                info.is_local,
3977                "{label} is still local — which is exactly why is_local cannot stand in"
3978            );
3979        }
3980
3981        // Raw vLLM-MLX is independently owned and therefore remote even when
3982        // its endpoint happens to use a loopback spelling. CAR neither
3983        // downloads its weights nor imposes the client machine's hardware
3984        // requirements on it.
3985        let external = test_generate_schema(
3986            "car894/external-model",
3987            "external-model",
3988            ModelSource::VllmMlx {
3989                endpoint: "http://localhost:8000".into(),
3990                model_name: "mlx-community/car894-test-4bit".into(),
3991            },
3992        );
3993        assert!(!external.is_local());
3994        assert!(external.is_remote());
3995        assert!(!external.requires_apple_silicon());
3996        let info = ModelInfo::from(&external);
3997        assert!(!info.is_local);
3998        assert!(
3999            !info.downloads_weights,
4000            "external vllm-mlx owns its weights, so CAR installs nothing"
4001        );
4002    }
4003
4004    #[test]
4005    fn model_info_classifies_only_raw_vllm_mlx_as_operator_managed_external() {
4006        for endpoint in ["http://localhost:8000", "https://models.example.invalid/v1"] {
4007            let schema = test_generate_schema(
4008                "external/model",
4009                "external-model",
4010                ModelSource::VllmMlx {
4011                    endpoint: endpoint.into(),
4012                    model_name: "mlx-community/external-model".into(),
4013                },
4014            );
4015            let info = ModelInfo::from(&schema);
4016            assert!(info.operator_managed_external_runtime);
4017            assert_eq!(
4018                serde_json::to_value(info).unwrap()["operator_managed_external_runtime"],
4019                true
4020            );
4021        }
4022
4023        for source in [
4024            ModelSource::RemoteApi {
4025                endpoint: "https://cloud.example.invalid/v1".into(),
4026                api_key_env: "CAR_TEST_KEY".into(),
4027                api_key_envs: vec![],
4028                api_version: None,
4029                protocol: crate::schema::ApiProtocol::OpenAiCompat,
4030            },
4031            ModelSource::ManagedVllmMlx {
4032                hf_repo: "mlx-community/car-owned-model".into(),
4033                hf_weight_file: None,
4034            },
4035        ] {
4036            assert!(
4037                !ModelInfo::from(&test_generate_schema(
4038                    "not-external/model",
4039                    "not-external-model",
4040                    source,
4041                ))
4042                .operator_managed_external_runtime
4043            );
4044        }
4045    }
4046
4047    /// The fresh-machine state from Parslee-ai/car#894, end to end: an MLX
4048    /// entry with a declared `hf_repo` and an EMPTY models dir. It is
4049    /// `available` (ensure_local lazy-downloads on first use, #164) and
4050    /// simultaneously NOT `weights_ready` (nothing has been fetched). Those
4051    /// two facts are what `car models list` and `car doctor` were each
4052    /// reporting in isolation, which is why they disagreed.
4053    #[test]
4054    fn fresh_machine_mlx_entry_is_available_but_not_weights_ready() {
4055        let mut reg = test_registry();
4056        let id = "mlx-community/car894-fresh-4bit";
4057        reg.register(test_generate_schema(
4058            id,
4059            "car894-fresh-4bit",
4060            ModelSource::Mlx {
4061                hf_repo: "mlx-community/car894-fresh-4bit".into(),
4062                hf_weight_file: None,
4063            },
4064        ));
4065
4066        let registered = reg
4067            .get(id)
4068            .expect("the model just registered must be in the registry");
4069        let info = ModelInfo::from(registered);
4070
4071        // The models dir is a fresh TempDir, so nothing is downloaded.
4072        assert!(
4073            !registered.weights_ready,
4074            "an empty models dir means no weights on disk"
4075        );
4076        assert!(
4077            !info.weights_ready,
4078            "the CLI-facing projection must report the same: nothing installed"
4079        );
4080
4081        #[cfg(car_mlxlm_swift_built)]
4082        {
4083            // With the Swift MLX backend built, this is the exact contradiction
4084            // #894 reported: runnable here, but not installed.
4085            assert!(
4086                registered.available,
4087                "a declared hf_repo makes an MLX entry runnable before download (#164)"
4088            );
4089            assert!(
4090                info.available,
4091                "the projection must keep reporting it as runnable"
4092            );
4093        }
4094        #[cfg(not(car_mlxlm_swift_built))]
4095        {
4096            // Without the Swift MLX backend it is neither runnable nor
4097            // installed — still not a contradiction.
4098            assert!(
4099                !registered.available,
4100                "the Swift MLX backend is absent, so it must not be runnable"
4101            );
4102            assert!(!info.available);
4103        }
4104    }
4105
4106    fn write_complete_mlx_snapshot(dir: &Path) {
4107        std::fs::create_dir_all(dir).unwrap();
4108        std::fs::write(dir.join("config.json"), b"{}").unwrap();
4109        std::fs::write(dir.join("tokenizer.json"), b"{}").unwrap();
4110        std::fs::write(
4111            dir.join("model.safetensors.index.json"),
4112            r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
4113        )
4114        .unwrap();
4115        std::fs::write(dir.join("model-00001-of-00002.safetensors"), b"one").unwrap();
4116        std::fs::write(dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
4117    }
4118
4119    #[test]
4120    fn complete_managed_and_shared_mlx_snapshots_are_physically_ready() {
4121        let schema = test_generate_schema(
4122            "mlx/qwen3-4b:4bit",
4123            "Qwen3-4B-MLX",
4124            ModelSource::Mlx {
4125                hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
4126                hf_weight_file: None,
4127            },
4128        );
4129        let root = tempfile::tempdir().unwrap();
4130        let managed = root.path().join("managed");
4131        let shared = root.path().join("shared");
4132
4133        write_complete_mlx_snapshot(&managed);
4134        assert!(mlx_weights_ready_at(&schema, &managed, None));
4135
4136        std::fs::remove_dir_all(&managed).unwrap();
4137        write_complete_mlx_snapshot(&shared);
4138        assert!(mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4139    }
4140
4141    #[test]
4142    fn zero_byte_gguf_is_not_physically_ready() {
4143        let schema = test_generate_schema(
4144            "qwen/qwen3-4b:q4_k_m",
4145            "Qwen3-4B",
4146            ModelSource::Local {
4147                hf_repo: "Qwen/Qwen3-4B-GGUF".into(),
4148                hf_filename: "model.gguf".into(),
4149                tokenizer_repo: "Qwen/Qwen3-4B".into(),
4150            },
4151        );
4152        let root = tempfile::tempdir().unwrap();
4153        let model_dir = root.path().join(&schema.name);
4154        std::fs::create_dir_all(&model_dir).unwrap();
4155        std::fs::write(model_dir.join("model.gguf"), b"").unwrap();
4156
4157        assert!(!physical_weights_ready(&schema, root.path()));
4158        std::fs::write(model_dir.join("model.gguf"), b"gguf").unwrap();
4159        assert!(physical_weights_ready(&schema, root.path()));
4160    }
4161
4162    #[test]
4163    fn shared_mlx_snapshot_missing_an_indexed_shard_is_not_physically_ready() {
4164        let schema = test_generate_schema(
4165            "mlx/qwen3-8b:4bit",
4166            "Qwen3-8B-MLX",
4167            ModelSource::Mlx {
4168                hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
4169                hf_weight_file: None,
4170            },
4171        );
4172        let root = tempfile::tempdir().unwrap();
4173        let managed = root.path().join("managed");
4174        let shared = root.path().join("shared");
4175        write_complete_mlx_snapshot(&shared);
4176        std::fs::remove_file(shared.join("model-00001-of-00002.safetensors")).unwrap();
4177
4178        assert!(!mlx_weights_ready_at(&schema, &managed, Some(&shared)));
4179    }
4180
4181    /// `refresh_availability` must probe each DISTINCT credential once, not
4182    /// once per model (car-releases#75).
4183    ///
4184    /// A credential probe is a keychain query on macOS — ~14 ms. The stock
4185    /// catalog is ~72 models over roughly a dozen providers, so probing
4186    /// per-model cost ~1 s per refresh; a snapshot runs per request, and
4187    /// `estimated_tokens` ran a second one, so a delegated call waited ~2 s
4188    /// before it ever reached the host's runner.
4189    ///
4190    /// Asserted by COUNTING probes rather than by timing: a latency assertion
4191    /// would be flaky on a loaded CI runner, and the invariant that actually
4192    /// matters is "O(providers), not O(models)".
4193    /// Parslee-ai/car#786 — the catalog must stop advertising a managed
4194    /// namespace once the gateway has said it has no upstream for it.
4195    ///
4196    /// This is the reported symptom directly: `car models list` showed all ten
4197    /// `parslee/openrouter/*` aliases as `avail=yes` while every one of them
4198    /// 503'd, because availability for those rows resolves to "is a Parslee
4199    /// session signed in" and nothing more. A harness picked one on the
4200    /// strength of that claim and lost a full benchmark sweep to it.
4201    ///
4202    /// Asserted through the real `curated_schemas()` rows rather than a
4203    /// fixture, because the bug lives in how THOSE rows derive availability.
4204    ///
4205    /// Isolates `CAR_HOME` for the same reason its credential sibling does.
4206    /// `note_gateway_unconfigured` writes `gateway-state.json` under the state
4207    /// root, and cleaning up afterwards is not enough: nextest runs every test
4208    /// in its own process, so while this one holds the flag a DIFFERENT process
4209    /// can load it and fail on an availability assertion it never made
4210    /// (Parslee-ai/car#986). The crate's serial lock cannot serialize across
4211    /// processes; a private state root can.
4212    #[test]
4213    fn a_gateway_that_reports_no_upstream_stops_being_advertised() {
4214        let _guard = crate::openrouter::test_environment_scope();
4215        // See the note above: `StateRootScope` is the RAII form, so a panic in
4216        // an assertion below cannot leave `CAR_HOME` pointing at a deleted dir.
4217        let _home = StateRootScope::new();
4218        crate::openrouter::clear_gateway_unconfigured();
4219
4220        let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
4221            .into_iter()
4222            .filter(|s| crate::openrouter::is_curated_managed_gateway_alias(&s.id))
4223            .collect();
4224        assert!(
4225            !managed.is_empty(),
4226            "precondition: the curated catalog must still carry managed aliases"
4227        );
4228
4229        let availability_of = |schema: &ModelSchema| match &schema.source {
4230            ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
4231                &schema.id,
4232                &schema.provider,
4233                provider,
4234                auth,
4235                // Signed in — the state in which the bug reported `yes`.
4236                true,
4237                &std::collections::HashMap::new(),
4238            ),
4239            other => panic!("managed aliases must be Proprietary, got {other:?}"),
4240        };
4241
4242        assert!(
4243            managed.iter().all(availability_of),
4244            "precondition: an authenticated session advertises these today"
4245        );
4246
4247        crate::openrouter::note_gateway_unconfigured();
4248        assert!(
4249            managed.iter().all(|s| !availability_of(s)),
4250            "after the gateway says it has no OpenRouter upstream, every alias in \
4251             the namespace must report unavailable — that claim is what cost the \
4252             benchmark sweep in #786"
4253        );
4254
4255        // Self-correcting: forgetting the observation restores the optimistic
4256        // claim, so an environment that later gains OpenRouter is not
4257        // permanently written off.
4258        crate::openrouter::clear_gateway_unconfigured();
4259        assert!(
4260            managed.iter().all(availability_of),
4261            "the suppression must be recoverable, not a one-way latch"
4262        );
4263    }
4264
4265    /// Parslee-ai/car#986 — building a registry must not clear the gateway
4266    /// observation as a side effect of construction.
4267    ///
4268    /// `new_with_catalog_public_key` ends in a `refresh_availability`, and that
4269    /// refresh forgets the session-scoped evidence whenever no Parslee session
4270    /// is present. #989 took the crate's serial lock in the two tests that call
4271    /// `refresh_availability` directly, but roughly twenty others reach the same
4272    /// clear just by constructing a registry, and none of them took the lock —
4273    /// so the class of bug survived the fix that was supposed to close it.
4274    ///
4275    /// This first case pins the harmless direction: a live session, so nothing
4276    /// is forgotten. `CAR_HOME` is isolated so the durable half of the
4277    /// observation lands in a temp dir rather than the developer's `~/.car`.
4278    #[test]
4279    fn constructing_a_registry_with_a_live_session_leaves_the_gateway_observation_alone() {
4280        let _guard = crate::openrouter::test_environment_scope();
4281        let home = StateRootScope::new();
4282
4283        crate::openrouter::note_gateway_unconfigured();
4284        let _registry = UnifiedRegistry::new_with_session(
4285            home.path().to_path_buf(),
4286            home.path().join("models"),
4287            None,
4288            SessionProbe::Fixed(true),
4289        );
4290
4291        let observed = crate::openrouter::gateway_unconfigured();
4292        let persisted = crate::openrouter::gateway_state_path().exists();
4293
4294        crate::openrouter::clear_gateway_unconfigured();
4295
4296        assert!(
4297            observed,
4298            "a signed-in session has no reason to forget what the gateway said"
4299        );
4300        assert!(
4301            persisted,
4302            "the durable half of the observation must survive construction too"
4303        );
4304    }
4305
4306    /// The other half of Parslee-ai/car#986: the production guarantee from
4307    /// Parslee-ai/car#786 is unchanged. When the session really is gone, the
4308    /// evidence learned about it is discarded — in memory and on disk — because
4309    /// the next sign-in may be to an org that has OpenRouter configured.
4310    ///
4311    /// Pinned rather than bypassed: the fix narrows *who* may forget, not
4312    /// whether forgetting still happens.
4313    #[test]
4314    fn constructing_a_registry_with_no_session_still_forgets_the_gateway_observation() {
4315        let _guard = crate::openrouter::test_environment_scope();
4316        let home = StateRootScope::new();
4317
4318        crate::openrouter::note_gateway_unconfigured();
4319        let recorded = crate::openrouter::gateway_unconfigured();
4320        let _registry = UnifiedRegistry::new_with_session(
4321            home.path().to_path_buf(),
4322            home.path().join("models"),
4323            None,
4324            SessionProbe::Fixed(false),
4325        );
4326
4327        let observed = crate::openrouter::gateway_unconfigured();
4328        let persisted = crate::openrouter::gateway_state_path().exists();
4329
4330        crate::openrouter::clear_gateway_unconfigured();
4331
4332        assert!(
4333            recorded,
4334            "precondition: the observation is on record before construction"
4335        );
4336        assert!(
4337            !observed,
4338            "sign-out must still discard the session-scoped verdict (#786)"
4339        );
4340        assert!(
4341            !persisted,
4342            "and the durable copy with it — otherwise the next sign-in inherits it from disk"
4343        );
4344    }
4345
4346    /// The case that actually failed before Parslee-ai/car#986 was fixed: an
4347    /// ordinary test registry, built the way every other test in this crate
4348    /// builds one, with no serial lock and no opinion about auth.
4349    ///
4350    /// On a machine with no Parslee session — a CI runner — construction alone
4351    /// used to wipe the observation, which is how
4352    /// `a_gateway_that_reports_no_upstream_stops_being_advertised` lost its flag
4353    /// between the `note_` call and the assertion under `cargo test`.
4354    ///
4355    /// Two assertions, deliberately. The observation one reproduces the CI
4356    /// condition and is the regression proper — but it only *fails* on main
4357    /// when the runner is signed out, so on a developer Mac with a live
4358    /// keychain session it would pass either way. The probe one holds
4359    /// regardless of the machine: the default a test registry is built with
4360    /// must be one that cannot forget session evidence, which is the whole
4361    /// content of the fix.
4362    #[test]
4363    fn an_ordinary_test_registry_does_not_disturb_a_separately_set_observation() {
4364        let _guard = crate::openrouter::test_environment_scope();
4365        let home = StateRootScope::new();
4366
4367        crate::openrouter::note_gateway_unconfigured();
4368        let _registry = UnifiedRegistry::new_with_state_root(
4369            home.path().to_path_buf(),
4370            home.path().join("models"),
4371        );
4372
4373        let observed = crate::openrouter::gateway_unconfigured();
4374
4375        crate::openrouter::clear_gateway_unconfigured();
4376
4377        assert!(
4378            observed,
4379            "constructing a registry is not a statement about the session, so it \
4380             must not erase an observation another test just recorded (#986)"
4381        );
4382        assert!(
4383            !SessionProbe::Inert.may_forget_session_evidence(),
4384            "the `cfg(test)` construction default must be a probe that answers \
4385             the session question without acting on it — this is the half of \
4386             the guarantee that does not depend on whether the runner happens \
4387             to be signed in"
4388        );
4389    }
4390
4391    /// Parslee-ai/car#887 — a signed-in session is not a WORKING one, and the
4392    /// catalog must stop advertising `parslee/*` once the server has rejected
4393    /// the credential.
4394    ///
4395    /// The reported symptom: on a machine with a stale sign-in, every routing
4396    /// pass offered the managed aliases as top-quality candidates and every
4397    /// request 401'd, so the router walked the fallback chain silently on every
4398    /// call. `access_token_is_available()` is existence-only, so `true` is
4399    /// exactly the state the bug reported as available.
4400    ///
4401    /// Isolates `CAR_HOME` rather than writing the observation into the
4402    /// developer's real `~/.car`; the sibling gateway test now does the same.
4403    #[test]
4404    fn a_rejected_credential_stops_the_managed_lane_being_advertised() {
4405        let _guard = crate::openrouter::test_environment_scope();
4406        let _home = StateRootScope::new();
4407        crate::parslee_credential::clear_credential_rejected();
4408
4409        let managed: Vec<ModelSchema> = crate::openrouter::curated_schemas()
4410            .into_iter()
4411            .filter(|s| s.provider == "parslee")
4412            .collect();
4413        assert!(
4414            !managed.is_empty(),
4415            "precondition: the curated catalog must still carry parslee rows"
4416        );
4417
4418        let availability_of = |schema: &ModelSchema| match &schema.source {
4419            ModelSource::Proprietary { provider, auth, .. } => proprietary_auth_available(
4420                &schema.id,
4421                &schema.provider,
4422                provider,
4423                auth,
4424                // Signed in — the state in which the bug reported `yes`.
4425                true,
4426                &std::collections::HashMap::new(),
4427            ),
4428            other => panic!("parslee rows must be Proprietary, got {other:?}"),
4429        };
4430
4431        assert!(
4432            managed.iter().all(availability_of),
4433            "precondition: an authenticated session advertises these today"
4434        );
4435
4436        crate::parslee_credential::note_credential_rejected();
4437        assert!(
4438            managed.iter().all(|s| !availability_of(s)),
4439            "after the server rejects the credential, EVERY parslee row must \
4440             report unavailable — unlike the gateway verdict this is not scoped \
4441             to the curated OpenRouter aliases, because a dead credential kills \
4442             the whole namespace"
4443        );
4444
4445        // Recoverable, not a one-way latch: the operator signs in again, the
4446        // next org lookup succeeds and clears the verdict.
4447        crate::parslee_credential::clear_credential_rejected();
4448        assert!(
4449            managed.iter().all(availability_of),
4450            "the suppression must lift once the credential works again"
4451        );
4452    }
4453
4454    /// **An explicit status request resolves env-or-keychain; the passive
4455    /// refresh resolves the environment alone.**
4456    ///
4457    /// The distinction is deliberate — the passive refresh runs on startup and
4458    /// catalog paths, where a secret-store read per provider would be wrong —
4459    /// but it reached a user-facing column. With a key kept in `car secrets`
4460    /// rather than exported, `car models list` reported a usable model as
4461    /// `RUNNABLE no`; measured with `OPENAI_API_KEY` in the keychain and
4462    /// nothing in the environment, `openai/gpt-5.5` read as not runnable while
4463    /// inference through the same binary worked.
4464    ///
4465    /// What this pins is the ENVIRONMENT half, which is all a test can reach:
4466    /// both modes must agree when the credential is exported, so the shared
4467    /// machinery is wired and the explicit mode has not silently become a
4468    /// no-op. The keychain half needs a populated OS store and cannot run in
4469    /// CI; it was verified by hand against the state above.
4470    #[test]
4471    fn an_explicit_status_refresh_sees_the_same_environment_credential() {
4472        let _environment = crate::openrouter::test_environment_scope();
4473        let tmp = TempDir::new().unwrap();
4474        let env_var = "CAR_TEST_EXPLICIT_STATUS_KEY";
4475        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
4476        let mut schema = test_generate_schema(
4477            "vendor/explicit-status",
4478            "explicit-status",
4479            ModelSource::RemoteApi {
4480                protocol: crate::schema::ApiProtocol::OpenAiCompat,
4481                endpoint: "https://example.invalid/v1".into(),
4482                api_key_env: env_var.into(),
4483                api_key_envs: vec![],
4484                api_version: None,
4485            },
4486        );
4487        schema.provider = "vendor".into();
4488        registry.register(schema);
4489
4490        std::env::remove_var(env_var);
4491        registry.refresh_availability();
4492        assert!(
4493            !registry.get("vendor/explicit-status").unwrap().available,
4494            "no credential anywhere: the passive refresh must report unavailable"
4495        );
4496        registry.refresh_availability_for_explicit_status();
4497        assert!(
4498            !registry.get("vendor/explicit-status").unwrap().available,
4499            "no credential anywhere: the explicit refresh must agree"
4500        );
4501
4502        std::env::set_var(env_var, "sk-present");
4503        registry.refresh_availability();
4504        assert!(
4505            registry.get("vendor/explicit-status").unwrap().available,
4506            "exported credential: the passive refresh must see it"
4507        );
4508        registry.refresh_availability_for_explicit_status();
4509        assert!(
4510            registry.get("vendor/explicit-status").unwrap().available,
4511            "exported credential: the explicit refresh must see it too — if this fails \
4512             the authoritative mode is resolving through a path that cannot see the \
4513             environment, and `car models list` is answering from something else again"
4514        );
4515        std::env::remove_var(env_var);
4516    }
4517
4518    #[test]
4519    fn refresh_availability_probes_each_credential_once_not_per_model() {
4520        // Serialized with the other tests that touch the gateway observation.
4521        // `refresh_availability` CLEARS it when no Parslee token is present
4522        // (see the `!parslee_oauth_available` branch above), which is exactly
4523        // the state a CI runner is in — so without this lock, running under
4524        // `cargo test` (one process per crate target, tests threaded in
4525        // parallel inside it) this test can wipe the flag that
4526        // `a_gateway_that_reports_no_upstream_stops_being_advertised` set,
4527        // between its `note_` call and its assertion. `cargo nextest` hides it
4528        // by giving every test its own process, and every per-PR job runs
4529        // nextest — which is why it surfaced in `check-windows` instead
4530        // (Parslee-ai/car#986). See docs/solutions/process-global-state-in-tests.md
4531        // for the family and the full list of shared-process CI runs.
4532        let _environment = crate::openrouter::test_environment_scope();
4533        let tmp = TempDir::new().unwrap();
4534        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
4535        for i in 0..25 {
4536            let mut schema = test_generate_schema(
4537                &format!("openrouter/model-{i}"),
4538                &format!("model-{i}"),
4539                ModelSource::RemoteApi {
4540                    protocol: crate::schema::ApiProtocol::OpenRouter,
4541                    endpoint: "https://openrouter.ai/api/v1".into(),
4542                    api_key_env: "OPENROUTER_API_KEY".into(),
4543                    api_key_envs: vec![],
4544                    api_version: None,
4545                },
4546            );
4547            schema.provider = "openrouter".into();
4548            registry.register(schema);
4549        }
4550
4551        crate::openrouter::reset_credential_source_call_count();
4552        registry.refresh_availability();
4553        let calls = crate::openrouter::credential_source_call_count();
4554
4555        assert_eq!(
4556            calls, 1,
4557            "refresh_availability probed the OpenRouter credential {calls} times for 25 models; \
4558             it must resolve each distinct credential once per refresh, not once per model"
4559        );
4560    }
4561
4562    /// `pull` on a `vllm-mlx` model must report the HuggingFace cache directory
4563    /// the external runtime will actually open — CAR's own
4564    /// `~/.car/models/<name>` layout is the wrong shape for a model a Python
4565    /// runtime resolves by repo id, and reporting the wrong path would make
4566    /// "where did my 16 GB go" unanswerable.
4567    #[test]
4568    fn a_vllm_mlx_pull_targets_the_shared_huggingface_cache() {
4569        let repo = "mlx-community/Qwen3.8-27B-4bit";
4570        let dir = crate::hf_cache::repo_dir(repo);
4571        assert!(
4572            dir.ends_with("models--mlx-community--Qwen3.8-27B-4bit"),
4573            "got {}",
4574            dir.display()
4575        );
4576        assert!(
4577            dir.parent().is_some_and(|p| p.ends_with("hub")),
4578            "must live under the HF cache's hub/ root, got {}",
4579            dir.display()
4580        );
4581    }
4582
4583    /// A raw `VllmMlx` row is operator-managed, even when its endpoint is a
4584    /// loopback URL. Only `ManagedVllmMlx` opts into CAR's provisioned runtime.
4585    #[test]
4586    fn external_vllm_mlx_does_not_become_managed_from_a_loopback_endpoint() {
4587        // Serialized with the other tests that touch the gateway observation.
4588        // `refresh_availability` CLEARS it when no Parslee token is present
4589        // (see the `!parslee_oauth_available` branch above), which is exactly
4590        // the state a CI runner is in — so without this lock, running under
4591        // `cargo test` (one process per crate target, tests threaded in
4592        // parallel inside it) this test can wipe the flag that
4593        // `a_gateway_that_reports_no_upstream_stops_being_advertised` set,
4594        // between its `note_` call and its assertion. `cargo nextest` hides it
4595        // by giving every test its own process, and every per-PR job runs
4596        // nextest — which is why it surfaced in `check-windows` instead
4597        // (Parslee-ai/car#986). See docs/solutions/process-global-state-in-tests.md
4598        // for the family and the full list of shared-process CI runs.
4599        let _environment = crate::openrouter::test_environment_scope();
4600        let tmp = TempDir::new().unwrap();
4601        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
4602        let schema = test_generate_schema(
4603            "vllm-mlx/arch-the-rust-backend-cannot-load",
4604            "external-only-model",
4605            ModelSource::VllmMlx {
4606                endpoint: "http://localhost:8000".into(),
4607                model_name: "mlx-community/Qwen3.8-27B-4bit".into(),
4608            },
4609        );
4610        registry.register(schema);
4611
4612        // Deliberately NOT set: no external endpoint has been declared healthy.
4613        assert!(
4614            std::env::var("VLLM_MLX_ENDPOINT").is_err(),
4615            "test precondition: VLLM_MLX_ENDPOINT must be unset"
4616        );
4617        registry.refresh_availability();
4618
4619        let model = registry
4620            .get("vllm-mlx/arch-the-rust-backend-cannot-load")
4621            .expect("registered model should be present");
4622        assert!(
4623            !model.available,
4624            "an external vllm-mlx row remains external even on loopback; only an \
4625             explicit ManagedVllmMlx source may use CAR's runtime"
4626        );
4627    }
4628
4629    #[test]
4630    fn user_config_load_and_save_force_community_trust() {
4631        let tmp = TempDir::new().unwrap();
4632        let models_dir = tmp.path().join("models");
4633        let config_path = tmp.path().join("models.json");
4634        let schema = test_generate_schema(
4635            "user/test-model",
4636            "user-test-model",
4637            ModelSource::RemoteApi {
4638                endpoint: "https://attacker.invalid/v1/chat/completions".into(),
4639                api_key_env: "CAR_USER_MODEL_TEST_KEY".into(),
4640                api_key_envs: vec![],
4641                api_version: None,
4642                protocol: crate::schema::ApiProtocol::OpenAiCompat,
4643            },
4644        );
4645        let mut omitted_tier = serde_json::to_value(schema.clone()).unwrap();
4646        omitted_tier.as_object_mut().unwrap().remove("trust_tier");
4647        std::fs::write(
4648            &config_path,
4649            serde_json::to_vec_pretty(&vec![omitted_tier]).unwrap(),
4650        )
4651        .unwrap();
4652
4653        let mut loaded = UnifiedRegistry::new_empty(models_dir.clone());
4654        loaded.load_user_config().unwrap();
4655        assert_eq!(
4656            loaded.get("user/test-model").unwrap().trust_tier,
4657            crate::schema::TrustTier::Community
4658        );
4659
4660        let mut persisted = UnifiedRegistry::new_empty(models_dir);
4661        persisted.register_user_model(schema);
4662        persisted.save_user_config().unwrap();
4663        let saved: Vec<ModelSchema> =
4664            serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
4665        assert_eq!(saved.len(), 1);
4666        assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
4667    }
4668
4669    #[test]
4670    fn persisted_user_model_cannot_shadow_managed_openrouter_alias() {
4671        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
4672        // which WRITES the process-global gateway observation. Full reasoning
4673        // on `TestRegistry` in this crate; family in
4674        // docs/solutions/process-global-state-in-tests.md.
4675        let _environment = crate::openrouter::test_environment_scope();
4676        let tmp = TempDir::new().unwrap();
4677        let models_dir = tmp.path().join("models");
4678        let config_path = tmp.path().join("models.json");
4679        let mut shadow = crate::openrouter::curated_schemas()
4680            .into_iter()
4681            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
4682            .unwrap();
4683        shadow.provider = "attacker".into();
4684        std::fs::write(
4685            &config_path,
4686            serde_json::to_vec_pretty(&vec![shadow]).unwrap(),
4687        )
4688        .unwrap();
4689
4690        let registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
4691        let actual = registry
4692            .get("parslee/openrouter/frontier-general")
4693            .expect("compiled managed alias must remain present");
4694        assert_eq!(actual.provider, "parslee");
4695        assert_eq!(
4696            crate::openrouter::canonical_managed_gateway_selector(actual),
4697            Some("parslee/openrouter/frontier-general")
4698        );
4699    }
4700
4701    fn signed_remote(id: &str, name: &str) -> ModelSchema {
4702        test_generate_schema(
4703            id,
4704            name,
4705            ModelSource::RemoteApi {
4706                endpoint: "https://catalog.example/v1".into(),
4707                api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
4708                api_key_envs: vec![],
4709                api_version: None,
4710                protocol: crate::schema::ApiProtocol::OpenAiCompat,
4711            },
4712        )
4713    }
4714
4715    #[test]
4716    fn a_user_registered_row_wins_a_live_signed_swap_and_stays_in_models_json() {
4717        let _environment = crate::openrouter::test_environment_scope();
4718        let tmp = TempDir::new().unwrap();
4719        let mut registry = UnifiedRegistry::new_with_catalog_public_key(
4720            tmp.path().to_path_buf(),
4721            tmp.path().join("models"),
4722            None,
4723        );
4724        let mut mine = signed_remote("shared/id", "mine");
4725        mine.context_length = 1234;
4726        registry.register_user_model(mine);
4727        let mut publishers = signed_remote("shared/id", "publishers");
4728        publishers.context_length = 9999;
4729        assert_eq!(
4730            registry.replace_signed_catalog(
4731                vec![publishers],
4732                None,
4733                &HashSet::new(),
4734                &HashSet::new()
4735            ),
4736            0
4737        );
4738        assert_eq!(registry.get("shared/id").unwrap().context_length, 1234);
4739        registry.save_user_config().unwrap();
4740        let saved = std::fs::read_to_string(tmp.path().join(USER_MODELS_FILE)).unwrap();
4741        assert!(saved.contains("1234") && !saved.contains("9999"), "{saved}");
4742        assert!(registry.unregister_user_model("shared/id").is_some());
4743    }
4744
4745    #[test]
4746    fn a_withdrawn_row_something_depends_on_stays_deprecated_until_nothing_does() {
4747        let _environment = crate::openrouter::test_environment_scope();
4748        let tmp = TempDir::new().unwrap();
4749        let mut registry = UnifiedRegistry::new_with_catalog_public_key(
4750            tmp.path().to_path_buf(),
4751            tmp.path().join("models"),
4752            None,
4753        );
4754        let used = signed_remote("signed/used", "used");
4755        let idle = signed_remote("signed/idle", "idle");
4756        assert_eq!(
4757            registry.replace_signed_catalog(
4758                vec![used, idle],
4759                None,
4760                &HashSet::new(),
4761                &HashSet::new()
4762            ),
4763            2
4764        );
4765        let keep: HashSet<String> = ["signed/used".to_string()].into();
4766        assert_eq!(
4767            registry.replace_signed_catalog(vec![], None, &keep, &HashSet::new()),
4768            0
4769        );
4770        assert!(
4771            registry.get("signed/idle").is_none(),
4772            "nothing depended on it"
4773        );
4774        let kept = registry.get("signed/used").expect("a dependent keeps it");
4775        assert!(kept.deprecated, "kept, but never suggested");
4776        // Republished: it comes back current.
4777        let used = signed_remote("signed/used", "used");
4778        assert_eq!(
4779            registry.replace_signed_catalog(vec![used], None, &keep, &HashSet::new()),
4780            1
4781        );
4782        assert!(!registry.get("signed/used").unwrap().deprecated);
4783        // Nothing depends on it any more: the next catalog that omits it drops it.
4784        registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
4785        assert!(registry.get("signed/used").is_none());
4786    }
4787
4788    #[test]
4789    fn a_kept_row_survives_a_restart_only_while_its_signed_catalog_verifies() {
4790        let _environment = crate::openrouter::test_environment_scope();
4791        let tmp = TempDir::new().unwrap();
4792        let open = |key: Option<&str>| {
4793            UnifiedRegistry::new_with_catalog_public_key(
4794                tmp.path().to_path_buf(),
4795                tmp.path().join("models"),
4796                key,
4797            )
4798        };
4799        let cache = crate::catalog::cache_path(tmp.path());
4800        let retained = crate::catalog::retained_path(tmp.path());
4801        let catalog = |models, version| {
4802            crate::catalog::signed_test_catalog(
4803                crate::catalog::CatalogDoc {
4804                    revoked: Vec::new(),
4805                    version,
4806                    models,
4807                },
4808                21,
4809            )
4810        };
4811        let (v1, key) = catalog(
4812            vec![
4813                signed_remote("signed/used", "used"),
4814                signed_remote("signed/idle", "idle"),
4815            ],
4816            1,
4817        );
4818        crate::catalog::save_verified(&cache, &v1).unwrap();
4819        let mut running = open(Some(&key));
4820        assert!(running.get("signed/used").is_some());
4821
4822        // v2 withdraws both; something depends on `used`.
4823        let (v2, _) = catalog(vec![], 2);
4824        let keep: HashSet<String> = ["signed/used".to_string()].into();
4825        running.replace_signed_catalog(
4826            v2.clone().into_models(),
4827            Some(std::sync::Arc::new(v2.envelope())),
4828            &keep,
4829            &HashSet::new(),
4830        );
4831        crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
4832        crate::catalog::save_verified(&cache, &v2).unwrap();
4833
4834        let restarted = open(Some(&key));
4835        let kept = restarted.get("signed/used").expect("kept across a restart");
4836        assert!(kept.deprecated);
4837        assert!(restarted.get("signed/idle").is_none());
4838        // Another key: nothing verifies, nothing comes back.
4839        let (_, other_key) = crate::catalog::signed_test_catalog(
4840            crate::catalog::CatalogDoc {
4841                revoked: Vec::new(),
4842                version: 9,
4843                models: vec![],
4844            },
4845            22,
4846        );
4847        assert!(open(Some(&other_key)).get("signed/used").is_none());
4848        // A tampered file: the envelope no longer verifies.
4849        let json = std::fs::read_to_string(&retained).unwrap();
4850        std::fs::write(&retained, json.replace("signed/idle", "signed/idlx")).unwrap();
4851        assert!(open(Some(&key)).get("signed/used").is_none());
4852    }
4853
4854    #[test]
4855    fn a_revoked_row_leaves_even_when_something_depends_on_it() {
4856        let _environment = crate::openrouter::test_environment_scope();
4857        let tmp = TempDir::new().unwrap();
4858        let mut registry = UnifiedRegistry::new_with_catalog_public_key(
4859            tmp.path().to_path_buf(),
4860            tmp.path().join("models"),
4861            None,
4862        );
4863        let none = HashSet::new();
4864        let bad: HashSet<String> = ["signed/bad".to_string()].into();
4865        registry.replace_signed_catalog(
4866            vec![signed_remote("signed/bad", "bad")],
4867            None,
4868            &none,
4869            &none,
4870        );
4871        // Withdrawn, but a lane depends on it: an ordinary withdrawal keeps it…
4872        registry.replace_signed_catalog(vec![], None, &bad, &none);
4873        assert!(registry.get("signed/bad").is_some());
4874        // …a revocation does not, even with nothing else changing.
4875        registry.replace_signed_catalog(vec![], None, &bad, &bad);
4876        assert!(registry.get("signed/bad").is_none());
4877        // And a catalog never registers a row it revokes itself.
4878        registry.replace_signed_catalog(
4879            vec![signed_remote("signed/bad", "bad")],
4880            None,
4881            &none,
4882            &bad,
4883        );
4884        assert!(registry.get("signed/bad").is_none());
4885        assert!(registry.retained_rows().is_empty());
4886
4887        // A revoked builtin is deprecated, and restored once it is not.
4888        let builtin = builtin_catalog()
4889            .into_iter()
4890            .find(|m| !m.deprecated)
4891            .expect("a current builtin");
4892        let revoked: HashSet<String> = [builtin.id.clone()].into();
4893        registry.replace_signed_catalog(vec![], None, &none, &revoked);
4894        assert!(registry.get(&builtin.id).unwrap().deprecated);
4895        registry.replace_signed_catalog(vec![], None, &none, &none);
4896        assert!(!registry.get(&builtin.id).unwrap().deprecated);
4897    }
4898
4899    #[test]
4900    fn a_restart_never_restores_a_row_the_current_catalog_revokes() {
4901        let _environment = crate::openrouter::test_environment_scope();
4902        let tmp = TempDir::new().unwrap();
4903        let catalog = |models, revoked, version| {
4904            crate::catalog::signed_test_catalog(
4905                crate::catalog::CatalogDoc {
4906                    version,
4907                    models,
4908                    revoked,
4909                },
4910                23,
4911            )
4912        };
4913        let (v1, key) = catalog(vec![signed_remote("signed/kept", "kept")], vec![], 1);
4914        let mut running = UnifiedRegistry::new_with_catalog_public_key(
4915            tmp.path().to_path_buf(),
4916            tmp.path().join("models"),
4917            Some(&key),
4918        );
4919        running.replace_signed_catalog(
4920            v1.clone().into_models(),
4921            Some(std::sync::Arc::new(v1.envelope())),
4922            &HashSet::new(),
4923            &HashSet::new(),
4924        );
4925        // v2 withdraws it while a lane depends on it: kept and recorded.
4926        let (v2, _) = catalog(vec![], vec![], 2);
4927        let keep: HashSet<String> = ["signed/kept".to_string()].into();
4928        running.replace_signed_catalog(
4929            vec![],
4930            Some(std::sync::Arc::new(v2.envelope())),
4931            &keep,
4932            &HashSet::new(),
4933        );
4934        let retained = crate::catalog::retained_path(tmp.path());
4935        crate::catalog::save_retained(&retained, &running.retained_rows()).unwrap();
4936        // v3 revokes it; the daemon restarts before anything rewrites the file.
4937        let (v3, _) = catalog(vec![], vec!["signed/kept".to_string()], 3);
4938        crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &v3).unwrap();
4939        let restarted = UnifiedRegistry::new_with_catalog_public_key(
4940            tmp.path().to_path_buf(),
4941            tmp.path().join("models"),
4942            Some(&key),
4943        );
4944        assert!(restarted.get("signed/kept").is_none());
4945    }
4946
4947    #[test]
4948    fn a_revoked_id_never_comes_back_from_the_discovery_cache() {
4949        let _environment = crate::openrouter::test_environment_scope();
4950        let tmp = TempDir::new().unwrap();
4951        let (catalog, key) = crate::catalog::signed_test_catalog(
4952            crate::catalog::CatalogDoc {
4953                version: 1,
4954                models: vec![],
4955                revoked: vec!["discovered/bad".to_string()],
4956            },
4957            24,
4958        );
4959        crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &catalog).unwrap();
4960        // The unsigned provider cache still lists the revoked id, beside one
4961        // nothing revokes.
4962        crate::discovery::save_cache(
4963            &crate::discovery::cache_path(&tmp.path().join("models")),
4964            &[
4965                signed_remote("discovered/bad", "bad"),
4966                signed_remote("discovered/fine", "fine"),
4967            ],
4968        )
4969        .unwrap();
4970        let restarted = UnifiedRegistry::new_with_catalog_public_key(
4971            tmp.path().to_path_buf(),
4972            tmp.path().join("models"),
4973            Some(&key),
4974        );
4975        assert!(restarted.get("discovered/bad").is_none());
4976        assert!(restarted.get("discovered/fine").is_some());
4977
4978        // A revocation that arrives while the daemon runs removes a row the
4979        // discovery cache already registered.
4980        let mut running = UnifiedRegistry::new_with_catalog_public_key(
4981            tmp.path().to_path_buf(),
4982            tmp.path().join("models"),
4983            None,
4984        );
4985        assert!(running.get("discovered/bad").is_some());
4986        let revoked: HashSet<String> = ["discovered/bad".to_string()].into();
4987        running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
4988        assert!(running.get("discovered/bad").is_none());
4989        assert!(running.get("discovered/fine").is_some());
4990
4991        // Lifting the revocation restores the cached row without a restart.
4992        running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
4993        assert!(running.get("discovered/bad").is_some());
4994        assert!(!running.is_revoked("discovered/bad"));
4995
4996        // A row the user registered under that id is theirs: a revocation
4997        // leaves it, and lifting one never replaces it with the cached row.
4998        running.register_user_model(signed_remote("discovered/bad", "mine"));
4999        running.replace_signed_catalog(vec![], None, &HashSet::new(), &revoked);
5000        assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5001        running.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5002        assert_eq!(running.get("discovered/bad").unwrap().name, "mine");
5003    }
5004
5005    #[test]
5006    fn a_signed_overlay_updates_a_builtins_measurements_never_its_weights() {
5007        let _environment = crate::openrouter::test_environment_scope();
5008        let tmp = TempDir::new().unwrap();
5009        let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5010            tmp.path().to_path_buf(),
5011            tmp.path().join("models"),
5012            None,
5013        );
5014        let compiled = builtin_catalog()
5015            .into_iter()
5016            .find(|m| m.public_benchmarks.iter().any(|b| b.name == "car-judged"))
5017            .expect("a scored builtin");
5018        let mut remeasured = compiled.clone();
5019        remeasured.public_benchmarks[0].score = 0.42;
5020        remeasured.public_benchmarks[0].runs = Some(3);
5021        remeasured.context_length = compiled.context_length + 1;
5022        remeasured.name = "renamed by the publisher".into();
5023        assert_eq!(
5024            registry.replace_signed_catalog(
5025                vec![remeasured.clone()],
5026                None,
5027                &HashSet::new(),
5028                &HashSet::new()
5029            ),
5030            1
5031        );
5032        let row = registry.get(&compiled.id).unwrap();
5033        assert_eq!(row.public_benchmarks[0].score, 0.42);
5034        assert_eq!(row.public_benchmarks[0].runs, Some(3));
5035        assert_eq!(
5036            row.context_length, compiled.context_length,
5037            "not an overlay field"
5038        );
5039        assert_eq!(row.name, compiled.name, "not an overlay field");
5040        // A catalog that omits it restores what the build shipped.
5041        registry.replace_signed_catalog(vec![], None, &HashSet::new(), &HashSet::new());
5042        assert_eq!(
5043            registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5044            compiled.public_benchmarks[0].score
5045        );
5046        // Different weights under a builtin id: refused outright.
5047        let mut repointed = remeasured;
5048        repointed.source = signed_remote("x/y", "y").source;
5049        assert_eq!(
5050            registry.replace_signed_catalog(
5051                vec![repointed],
5052                None,
5053                &HashSet::new(),
5054                &HashSet::new()
5055            ),
5056            0
5057        );
5058        assert_eq!(
5059            registry.get(&compiled.id).unwrap().public_benchmarks[0].score,
5060            compiled.public_benchmarks[0].score
5061        );
5062    }
5063
5064    #[test]
5065    fn user_config_persistence_excludes_signed_rows_and_keeps_builtin_tagged_user_rows() {
5066        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
5067        // which WRITES the process-global gateway observation. Full reasoning
5068        // on `TestRegistry` in this crate; family in
5069        // docs/solutions/process-global-state-in-tests.md.
5070        let _environment = crate::openrouter::test_environment_scope();
5071        let tmp = TempDir::new().unwrap();
5072        let models_dir = tmp.path().join("models");
5073        let config_path = tmp.path().join("models.json");
5074
5075        let signed = test_generate_schema(
5076            "signed/catalog-only",
5077            "signed-catalog-only",
5078            ModelSource::RemoteApi {
5079                endpoint: "https://catalog.example/v1".into(),
5080                api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5081                api_key_envs: vec![],
5082                api_version: None,
5083                protocol: crate::schema::ApiProtocol::OpenAiCompat,
5084            },
5085        );
5086        assert!(!signed.tags.iter().any(|tag| tag == "builtin"));
5087        let (verified, public_key) = crate::catalog::signed_test_catalog(
5088            crate::catalog::CatalogDoc {
5089                revoked: Vec::new(),
5090                version: 81,
5091                models: vec![signed],
5092            },
5093            81,
5094        );
5095        crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
5096
5097        let mut registry = UnifiedRegistry::new_with_catalog_public_key(
5098            tmp.path().to_path_buf(),
5099            models_dir.clone(),
5100            Some(public_key.as_str()),
5101        );
5102        let mut user = test_generate_schema(
5103            "user/builtin-tagged",
5104            "user-builtin-tagged",
5105            ModelSource::RemoteApi {
5106                endpoint: "https://user.example/v1".into(),
5107                api_key_env: "USER_MODEL_TEST_KEY".into(),
5108                api_key_envs: vec![],
5109                api_version: None,
5110                protocol: crate::schema::ApiProtocol::OpenAiCompat,
5111            },
5112        );
5113        user.tags.push("builtin".into());
5114        registry.register_user_model(user);
5115        registry.save_user_config().unwrap();
5116
5117        let saved: Vec<ModelSchema> =
5118            serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
5119        assert_eq!(
5120            saved
5121                .iter()
5122                .map(|model| model.id.as_str())
5123                .collect::<Vec<_>>(),
5124            vec!["user/builtin-tagged"],
5125            "models.json must contain only explicitly user-registered rows"
5126        );
5127        assert_eq!(saved[0].trust_tier, crate::schema::TrustTier::Community);
5128
5129        let mut restarted = UnifiedRegistry::new_with_catalog_public_key(
5130            tmp.path().to_path_buf(),
5131            models_dir,
5132            Some(public_key.as_str()),
5133        );
5134        assert_eq!(
5135            restarted.get("signed/catalog-only").unwrap().trust_tier,
5136            crate::schema::TrustTier::Curated,
5137            "user persistence must not demote an unrelated signed catalog row"
5138        );
5139        assert_eq!(
5140            restarted.get("user/builtin-tagged").unwrap().trust_tier,
5141            crate::schema::TrustTier::Community
5142        );
5143        let mut signed_shadow = restarted.get("signed/catalog-only").unwrap().clone();
5144        signed_shadow.name = "user-shadow-of-signed-row".into();
5145        restarted.register_user_model(signed_shadow);
5146        assert_eq!(
5147            restarted.get("signed/catalog-only").unwrap().name,
5148            "signed-catalog-only",
5149            "a user row must not shadow a signature-verified project exact id"
5150        );
5151    }
5152
5153    #[test]
5154    fn empty_user_config_save_clears_stale_rows() {
5155        let tmp = TempDir::new().unwrap();
5156        let models_dir = tmp.path().join("models");
5157        let config_path = tmp.path().join("models.json");
5158        let stale = test_generate_schema(
5159            "user/stale",
5160            "stale",
5161            ModelSource::RemoteApi {
5162                endpoint: "https://stale.example/v1".into(),
5163                api_key_env: "STALE_USER_MODEL_TEST_KEY".into(),
5164                api_key_envs: vec![],
5165                api_version: None,
5166                protocol: crate::schema::ApiProtocol::OpenAiCompat,
5167            },
5168        );
5169        std::fs::write(
5170            &config_path,
5171            serde_json::to_vec_pretty(&vec![stale]).unwrap(),
5172        )
5173        .unwrap();
5174
5175        UnifiedRegistry::new_empty(models_dir)
5176            .save_user_config()
5177            .unwrap();
5178
5179        let saved: Vec<ModelSchema> =
5180            serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5181        assert!(
5182            saved.is_empty(),
5183            "saving an empty user set must overwrite stale models.json rows"
5184        );
5185    }
5186
5187    #[test]
5188    fn unregister_then_save_removes_the_user_row_from_disk() {
5189        let tmp = TempDir::new().unwrap();
5190        let models_dir = tmp.path().join("models");
5191        let config_path = tmp.path().join("models.json");
5192        let mut registry = UnifiedRegistry::new_empty(models_dir);
5193        registry.register_project_model(test_generate_schema(
5194            "signed/not-user-removable",
5195            "not-user-removable",
5196            ModelSource::RemoteApi {
5197                endpoint: "https://catalog.example/v1".into(),
5198                api_key_env: "SIGNED_CATALOG_TEST_KEY".into(),
5199                api_key_envs: vec![],
5200                api_version: None,
5201                protocol: crate::schema::ApiProtocol::OpenAiCompat,
5202            },
5203        ));
5204        assert!(
5205            registry
5206                .unregister_user_model("signed/not-user-removable")
5207                .is_none(),
5208            "the user boundary cannot unregister an untracked catalog row"
5209        );
5210        assert!(registry.get("signed/not-user-removable").is_some());
5211        registry.register_user_model(test_generate_schema(
5212            "user/removable",
5213            "removable",
5214            ModelSource::RemoteApi {
5215                endpoint: "https://user.example/v1".into(),
5216                api_key_env: "REMOVABLE_USER_MODEL_TEST_KEY".into(),
5217                api_key_envs: vec![],
5218                api_version: None,
5219                protocol: crate::schema::ApiProtocol::OpenAiCompat,
5220            },
5221        ));
5222        registry.save_user_config().unwrap();
5223        assert!(registry.unregister_user_model("user/removable").is_some());
5224        registry.save_user_config().unwrap();
5225
5226        let saved: Vec<ModelSchema> =
5227            serde_json::from_slice(&std::fs::read(config_path).unwrap()).unwrap();
5228        assert!(saved.is_empty());
5229    }
5230
5231    /// The daemon's `models.register` handler writes through
5232    /// [`user_config_path`]; the registry reads through the state root it was
5233    /// built with. Those two resolvers must land on one file.
5234    ///
5235    /// They did not. The write side moved to `CAR_HOME` while the read side
5236    /// stayed `<models_dir>/../models.json` — and `models_dir` is deliberately
5237    /// the machine-shared weights cache, which does NOT move. So against a
5238    /// relocated daemon the registration was persisted to
5239    /// `$CAR_HOME/models.json`, the next boot read `~/.car/models.json`, and
5240    /// the model silently never appeared. No error anywhere; the handler even
5241    /// reported the path it had written.
5242    #[test]
5243    fn a_registration_written_under_car_home_is_the_file_the_registry_reads() {
5244        let _environment = crate::openrouter::test_environment_scope();
5245        let prior = std::env::var_os(car_home::ENV_VAR);
5246
5247        let state_root = TempDir::new().unwrap();
5248        // Deliberately NOT inside the state root — this stands in for the
5249        // machine-shared `~/.car/models`, which stays put under `CAR_HOME`.
5250        let weights = TempDir::new().unwrap();
5251        let models_dir = weights.path().join("models");
5252        std::fs::create_dir_all(&models_dir).unwrap();
5253
5254        unsafe { std::env::set_var(car_home::ENV_VAR, state_root.path()) };
5255
5256        // Exactly what `handle_models_register` resolves and writes.
5257        let write_path = user_config_path().expect("CAR_HOME must resolve a models.json path");
5258        assert_eq!(write_path, state_root.path().join(USER_MODELS_FILE));
5259        let registered = test_generate_schema(
5260            "user/relocated-daemon-model",
5261            "relocated-daemon-model",
5262            ModelSource::RemoteApi {
5263                endpoint: "https://relocated.example/v1".into(),
5264                api_key_env: "RELOCATED_DAEMON_MODEL_TEST_KEY".into(),
5265                api_key_envs: vec![],
5266                api_version: None,
5267                protocol: crate::schema::ApiProtocol::OpenAiCompat,
5268            },
5269        );
5270        std::fs::write(
5271            &write_path,
5272            serde_json::to_vec_pretty(&vec![registered]).unwrap(),
5273        )
5274        .unwrap();
5275
5276        // …and exactly what the next daemon boot constructs.
5277        let registry = UnifiedRegistry::new(models_dir.clone());
5278
5279        match prior {
5280            Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
5281            None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
5282        }
5283
5284        assert!(
5285            registry.get("user/relocated-daemon-model").is_some(),
5286            "the registry must load the models.json that `models.register` wrote; \
5287             it looked at {} instead",
5288            registry.user_config_path.display(),
5289        );
5290        assert_eq!(registry.user_config_path, write_path);
5291        assert!(
5292            !weights.path().join(USER_MODELS_FILE).exists(),
5293            "nothing may be written beside the shared weights cache",
5294        );
5295    }
5296
5297    #[test]
5298    fn ready_without_download_is_strict_for_local_model_files() {
5299        let tmp = TempDir::new().unwrap();
5300        let models = tmp.path().join("models");
5301        let mut reg = UnifiedRegistry::new_empty(models.clone());
5302        reg.register(test_generate_schema(
5303            "local/test",
5304            "TestLocal",
5305            ModelSource::Local {
5306                hf_repo: "example/repo".into(),
5307                hf_filename: "model.gguf".into(),
5308                tokenizer_repo: "example/repo".into(),
5309            },
5310        ));
5311
5312        assert_eq!(reg.ready_without_download("local/test"), Some(false));
5313
5314        let dir = models.join("TestLocal");
5315        std::fs::create_dir_all(&dir).unwrap();
5316        std::fs::write(dir.join("model.gguf"), b"weights").unwrap();
5317        assert_eq!(
5318            reg.ready_without_download("local/test"),
5319            Some(false),
5320            "tokenizer is required too"
5321        );
5322        std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
5323        assert_eq!(reg.ready_without_download("local/test"), Some(true));
5324    }
5325
5326    #[test]
5327    fn ready_without_download_rejects_mlx_config_only_stub() {
5328        let tmp = TempDir::new().unwrap();
5329        let models = tmp.path().join("models");
5330        let mut reg = UnifiedRegistry::new_empty(models.clone());
5331        reg.register(test_generate_schema(
5332            "mlx/test",
5333            "TestMlx",
5334            ModelSource::Mlx {
5335                hf_repo: "example/repo".into(),
5336                hf_weight_file: None,
5337            },
5338        ));
5339
5340        let dir = models.join("TestMlx");
5341        std::fs::create_dir_all(&dir).unwrap();
5342        std::fs::write(dir.join("config.json"), "{}").unwrap();
5343        std::fs::write(dir.join("tokenizer.json"), "{}").unwrap();
5344        assert_eq!(
5345            reg.ready_without_download("mlx/test"),
5346            Some(false),
5347            "config/tokenizer stubs must not start assistant inference"
5348        );
5349
5350        std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
5351        assert_eq!(reg.ready_without_download("mlx/test"), Some(true));
5352    }
5353
5354    fn write_mlx_dir(root: &Path, name: &str, model_type: &str) {
5355        let dir = root.join(name);
5356        std::fs::create_dir_all(&dir).unwrap();
5357        std::fs::write(
5358            dir.join("config.json"),
5359            serde_json::json!({
5360                "model_type": model_type,
5361                "max_position_embeddings": 40_960,
5362                "quantization": { "bits": 8, "group_size": 32, "mode": "mxfp8" },
5363            })
5364            .to_string(),
5365        )
5366        .unwrap();
5367        std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
5368    }
5369
5370    struct ScopedEnvVar {
5371        name: &'static str,
5372        previous: Option<std::ffi::OsString>,
5373    }
5374
5375    impl ScopedEnvVar {
5376        fn set(name: &'static str, value: &Path) -> Self {
5377            let previous = std::env::var_os(name);
5378            // SAFETY: acquisition tests hold `test_environment_scope_async` for
5379            // the lifetime of this process-global environment mutation.
5380            unsafe { std::env::set_var(name, value) };
5381            Self { name, previous }
5382        }
5383
5384        fn unset(name: &'static str) -> Self {
5385            let previous = std::env::var_os(name);
5386            // SAFETY: as for `set`.
5387            unsafe { std::env::remove_var(name) };
5388            Self { name, previous }
5389        }
5390    }
5391
5392    impl Drop for ScopedEnvVar {
5393        fn drop(&mut self) {
5394            // SAFETY: the environment mutex is still held because this guard is
5395            // declared after it and therefore drops first.
5396            unsafe {
5397                match self.previous.take() {
5398                    Some(value) => std::env::set_var(self.name, value),
5399                    None => std::env::remove_var(self.name),
5400                }
5401            }
5402        }
5403    }
5404
5405    #[derive(Default)]
5406    struct AcquisitionRecorder {
5407        events: std::sync::Mutex<Vec<DownloadEvent>>,
5408        replace_dir_on_started: std::sync::Mutex<Option<PathBuf>>,
5409    }
5410
5411    impl AcquisitionRecorder {
5412        fn replacing_dir_on_started(path: PathBuf) -> Self {
5413            Self {
5414                events: std::sync::Mutex::new(Vec::new()),
5415                replace_dir_on_started: std::sync::Mutex::new(Some(path)),
5416            }
5417        }
5418
5419        fn events(&self) -> Vec<DownloadEvent> {
5420            self.events.lock().unwrap().clone()
5421        }
5422    }
5423
5424    impl crate::download::DownloadProgress for AcquisitionRecorder {
5425        fn on_event(&self, event: &DownloadEvent) {
5426            self.events.lock().unwrap().push(event.clone());
5427            if matches!(event, DownloadEvent::Started { .. }) {
5428                if let Some(path) = self.replace_dir_on_started.lock().unwrap().take() {
5429                    std::fs::remove_dir_all(&path).unwrap();
5430                    std::fs::write(path, b"make create_dir_all fail before any network access")
5431                        .unwrap();
5432                }
5433            }
5434        }
5435    }
5436
5437    fn started_count(events: &[DownloadEvent]) -> usize {
5438        events
5439            .iter()
5440            .filter(|event| matches!(event, DownloadEvent::Started { .. }))
5441            .count()
5442    }
5443
5444    fn mlx_schema(id: &str, name: &str, hf_repo: &str) -> ModelSchema {
5445        test_generate_schema(
5446            id,
5447            name,
5448            ModelSource::Mlx {
5449                hf_repo: hf_repo.into(),
5450                hf_weight_file: None,
5451            },
5452        )
5453    }
5454
5455    #[test]
5456    fn installing_a_fetched_file_replaces_only_unusable_destinations() {
5457        let tmp = TempDir::new().unwrap();
5458        let src = tmp.path().join("fetched");
5459        std::fs::write(&src, b"fetched bytes").unwrap();
5460
5461        let missing = tmp.path().join("missing");
5462        install_fetched_file(&src, &missing).unwrap();
5463        assert_eq!(std::fs::read(&missing).unwrap(), b"fetched bytes");
5464
5465        let usable = tmp.path().join("usable");
5466        std::fs::write(&usable, b"keep these bytes").unwrap();
5467        let usable_before = std::fs::symlink_metadata(&usable).unwrap();
5468        let modified_before = usable_before.modified().unwrap();
5469        #[cfg(unix)]
5470        let inode_before = {
5471            use std::os::unix::fs::MetadataExt;
5472            usable_before.ino()
5473        };
5474        install_fetched_file(&src, &usable).unwrap();
5475        let usable_after = std::fs::symlink_metadata(&usable).unwrap();
5476        assert_eq!(std::fs::read(&usable).unwrap(), b"keep these bytes");
5477        assert_eq!(usable_after.modified().unwrap(), modified_before);
5478        #[cfg(unix)]
5479        {
5480            use std::os::unix::fs::MetadataExt;
5481            assert_eq!(usable_after.ino(), inode_before);
5482        }
5483
5484        let zero_byte = tmp.path().join("zero-byte");
5485        std::fs::write(&zero_byte, b"").unwrap();
5486        install_fetched_file(&src, &zero_byte).unwrap();
5487        assert_eq!(std::fs::read(&zero_byte).unwrap(), b"fetched bytes");
5488
5489        #[cfg(unix)]
5490        {
5491            let dangling = tmp.path().join("dangling");
5492            std::os::unix::fs::symlink(tmp.path().join("absent"), &dangling).unwrap();
5493            assert!(std::fs::symlink_metadata(&dangling).unwrap().is_symlink());
5494            install_fetched_file(&src, &dangling).unwrap();
5495            assert_eq!(std::fs::read(&dangling).unwrap(), b"fetched bytes");
5496        }
5497    }
5498
5499    // The fixture redirects the cache through env vars and unix paths; keep it
5500    // out of nightly Windows lib tests.
5501    #[cfg(unix)]
5502    #[tokio::test]
5503    async fn a_zero_byte_flux_auxiliary_file_is_not_accepted_as_present() {
5504        let _environment = crate::openrouter::test_environment_scope_async().await;
5505        let tmp = TempDir::new().unwrap();
5506        let home = tmp.path().join("home");
5507        let _home = ScopedEnvVar::set("HOME", &home);
5508        let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5509        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5510        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5511
5512        // `download_file` writes the resolved hub — `HF_HOME/hub` here, written
5513        // out literally so the fixture does not borrow the code under test.
5514        // Populate the exact base-model pointer it asks for, fully offline.
5515        let cached_auxiliary = tmp
5516            .path()
5517            .join("hf-home/hub")
5518            .join("models--Freepik--flux.1-lite-8B")
5519            .join("snapshots/fixture/tokenizer_2/tokenizer.json");
5520        std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
5521        std::fs::write(&cached_auxiliary, b"repaired tokenizer").unwrap();
5522        let refs = tmp
5523            .path()
5524            .join("hf-home/hub/models--Freepik--flux.1-lite-8B/refs");
5525        std::fs::create_dir_all(&refs).unwrap();
5526        std::fs::write(refs.join("main"), b"fixture").unwrap();
5527
5528        let models = tmp.path().join("models");
5529        let name = "Flux-1.lite-8B-MLX-Q4";
5530        write_mlx_dir(&models, name, "flux");
5531        let auxiliary = models.join(name).join("tokenizer_2/tokenizer.json");
5532        std::fs::create_dir_all(auxiliary.parent().unwrap()).unwrap();
5533        std::fs::write(&auxiliary, b"").unwrap();
5534        let mut reg = UnifiedRegistry::new_empty(models.clone());
5535        reg.register(mlx_schema(
5536            "mlx/flux-zero-byte-aux",
5537            name,
5538            "mlx-community/Flux-1.lite-8B-MLX-Q4",
5539        ));
5540        let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5541        let sink = ProgressSink::new(recorder.clone());
5542
5543        let result = reg
5544            .acquire_and_ensure("mlx/flux-zero-byte-aux", &sink, false, None)
5545            .await;
5546        let events = recorder.events();
5547        assert!(matches!(
5548            events.first(),
5549            Some(DownloadEvent::Started { .. })
5550        ));
5551        assert_eq!(started_count(&events), 1);
5552        assert!(
5553            result.is_err() || crate::download::cache_file_usable(&auxiliary),
5554            "acquisition must replace the zero-byte auxiliary file or report failure"
5555        );
5556        if result.is_ok() {
5557            assert_eq!(std::fs::read(auxiliary).unwrap(), b"repaired tokenizer");
5558        }
5559        // The auxiliary came from a file the hub already held: nothing was
5560        // fetched, so nothing is recorded as CAR's.
5561        assert!(
5562            !tmp.path()
5563                .join("model-management")
5564                .join(crate::retire::PROVENANCE_FILE)
5565                .exists(),
5566            "reusing a cached file must not record it as a CAR download"
5567        );
5568    }
5569
5570    // The fixture redirects the cache through env vars and unix paths; keep it
5571    // out of nightly Windows lib tests.
5572    #[cfg(unix)]
5573    #[tokio::test]
5574    async fn managed_flux_missing_auxiliary_does_not_fall_through_to_hf_snapshot() {
5575        let _environment = crate::openrouter::test_environment_scope_async().await;
5576        let tmp = TempDir::new().unwrap();
5577        let home = tmp.path().join("home");
5578        let hf_home = tmp.path().join("hf-home");
5579        let _home = ScopedEnvVar::set("HOME", &home);
5580        let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
5581        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5582        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5583
5584        // The managed repair fetches Flux's auxiliary from the base model.
5585        // Populate the resolved hub (`HF_HOME/hub`, written out literally) so
5586        // that repair succeeds offline.
5587        let cached_auxiliary = hf_home
5588            .join("hub")
5589            .join("models--Freepik--flux.1-lite-8B")
5590            .join("snapshots/fixture/tokenizer_2/tokenizer.json");
5591        std::fs::create_dir_all(cached_auxiliary.parent().unwrap()).unwrap();
5592        std::fs::write(&cached_auxiliary, b"managed repair tokenizer").unwrap();
5593        let refs = hf_home.join("hub/models--Freepik--flux.1-lite-8B/refs");
5594        std::fs::create_dir_all(&refs).unwrap();
5595        std::fs::write(refs.join("main"), b"fixture").unwrap();
5596
5597        let models = tmp.path().join("models");
5598        let name = "Flux-1.lite-8B-MLX-Q4";
5599        let managed = models.join(name);
5600        write_mlx_dir(&models, name, "flux");
5601        let managed_auxiliary = managed.join("tokenizer_2/tokenizer.json");
5602
5603        // Also provide a fully reusable shared snapshot. The managed install
5604        // must still be repaired and returned, preserving base precedence.
5605        let snapshot = hf_home
5606            .join("hub/models--mlx-community--Flux-1.lite-8B-MLX-Q4")
5607            .join("snapshots/fixture");
5608        write_complete_mlx_snapshot(&snapshot);
5609        let snapshot_auxiliary = snapshot.join("tokenizer_2/tokenizer.json");
5610        std::fs::create_dir_all(snapshot_auxiliary.parent().unwrap()).unwrap();
5611        std::fs::write(&snapshot_auxiliary, b"snapshot tokenizer").unwrap();
5612
5613        let id = "mlx/flux-managed-precedence";
5614        let mut reg = UnifiedRegistry::new_empty(models);
5615        reg.register(mlx_schema(id, name, "mlx-community/Flux-1.lite-8B-MLX-Q4"));
5616        let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5617        let sink = ProgressSink::new(recorder.clone());
5618
5619        let path = reg
5620            .acquire_and_ensure(id, &sink, false, None)
5621            .await
5622            .unwrap();
5623
5624        assert_eq!(path, managed);
5625        assert_eq!(
5626            std::fs::read(managed_auxiliary).unwrap(),
5627            b"managed repair tokenizer"
5628        );
5629        let events = recorder.events();
5630        assert!(matches!(
5631            events.first(),
5632            Some(DownloadEvent::Started { .. })
5633        ));
5634        assert_eq!(started_count(&events), 1);
5635    }
5636
5637    #[tokio::test]
5638    async fn reusing_a_complete_managed_mlx_dir_emits_no_acquisition_lifecycle() {
5639        let _environment = crate::openrouter::test_environment_scope_async().await;
5640        let tmp = TempDir::new().unwrap();
5641        let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5642        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5643        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5644        let models = tmp.path().join("models");
5645        let mut reg = UnifiedRegistry::new_empty(models.clone());
5646        reg.register(mlx_schema(
5647            "mlx/reuse-complete",
5648            "Reuse-Complete-MLX",
5649            "example/reuse-complete",
5650        ));
5651        write_mlx_dir(&models, "Reuse-Complete-MLX", "qwen3");
5652        std::fs::write(
5653            models.join("Reuse-Complete-MLX").join("tokenizer.json"),
5654            b"{}",
5655        )
5656        .unwrap();
5657        let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5658        let sink = ProgressSink::new(recorder.clone());
5659
5660        let path = reg
5661            .ensure_local_with_progress("mlx/reuse-complete", &sink)
5662            .await
5663            .unwrap();
5664
5665        assert_eq!(path, models.join("Reuse-Complete-MLX"));
5666        assert!(recorder.events().is_empty());
5667    }
5668
5669    #[tokio::test]
5670    async fn reusing_a_managed_mlx_dir_without_tokenizer_json_emits_no_lifecycle() {
5671        let _environment = crate::openrouter::test_environment_scope_async().await;
5672        let tmp = TempDir::new().unwrap();
5673        let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5674        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5675        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5676        let models = tmp.path().join("models");
5677        let mut reg = UnifiedRegistry::new_empty(models.clone());
5678        reg.register(mlx_schema(
5679            "mlx/reuse-no-tokenizer",
5680            "Reuse-No-Tokenizer-MLX",
5681            "example/reuse-no-tokenizer",
5682        ));
5683        write_mlx_dir(&models, "Reuse-No-Tokenizer-MLX", "qwen3");
5684        let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5685        let sink = ProgressSink::new(recorder.clone());
5686
5687        let path = reg
5688            .ensure_local_with_progress("mlx/reuse-no-tokenizer", &sink)
5689            .await
5690            .unwrap();
5691
5692        assert_eq!(path, models.join("Reuse-No-Tokenizer-MLX"));
5693        assert!(recorder.events().is_empty());
5694    }
5695
5696    #[tokio::test]
5697    async fn reusing_a_complete_hf_snapshot_emits_no_acquisition_lifecycle() {
5698        let _environment = crate::openrouter::test_environment_scope_async().await;
5699        let tmp = TempDir::new().unwrap();
5700        let hf_home = tmp.path().join("hf-home");
5701        let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
5702        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5703        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5704        let models = tmp.path().join("models");
5705        let repo = "example/reuse-hf-snapshot";
5706        let snapshot = hf_home
5707            .join("hub")
5708            .join("models--example--reuse-hf-snapshot")
5709            .join("snapshots")
5710            .join("fixture");
5711        write_complete_mlx_snapshot(&snapshot);
5712        let mut reg = UnifiedRegistry::new_empty(models);
5713        reg.register(mlx_schema("mlx/reuse-hf", "Reuse-HF-MLX", repo));
5714        let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5715        let sink = ProgressSink::new(recorder.clone());
5716
5717        let path = reg
5718            .ensure_local_with_progress("mlx/reuse-hf", &sink)
5719            .await
5720            .unwrap();
5721
5722        assert_eq!(path, snapshot);
5723        assert!(recorder.events().is_empty());
5724    }
5725
5726    #[tokio::test]
5727    async fn a_zero_byte_managed_config_json_starts_acquisition() {
5728        let _environment = crate::openrouter::test_environment_scope_async().await;
5729        let tmp = TempDir::new().unwrap();
5730        let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5731        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5732        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5733        let models = tmp.path().join("models");
5734        let name = "Zero-Byte-Config-MLX";
5735        write_mlx_dir(&models, name, "qwen3");
5736        let model_dir = models.join(name);
5737        std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
5738        std::fs::write(model_dir.join("config.json"), b"").unwrap();
5739        let mut reg = UnifiedRegistry::new_empty(models);
5740        reg.register(mlx_schema(
5741            "mlx/zero-byte-config",
5742            name,
5743            "example/zero-byte-config",
5744        ));
5745        let recorder =
5746            std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
5747        let sink = ProgressSink::new(recorder.clone());
5748
5749        let result = reg
5750            .ensure_local_with_progress("mlx/zero-byte-config", &sink)
5751            .await;
5752
5753        let events = recorder.events();
5754        assert!(
5755            matches!(events.first(), Some(DownloadEvent::Started { .. })),
5756            "weights beside a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
5757        );
5758        assert_eq!(started_count(&events), 1);
5759        assert!(result.is_err());
5760    }
5761
5762    #[tokio::test]
5763    async fn a_zero_byte_hf_snapshot_config_json_starts_acquisition() {
5764        let _environment = crate::openrouter::test_environment_scope_async().await;
5765        let tmp = TempDir::new().unwrap();
5766        let hf_home = tmp.path().join("hf-home");
5767        let _hf_home = ScopedEnvVar::set("HF_HOME", &hf_home);
5768        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5769        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5770        let models = tmp.path().join("models");
5771        let name = "Zero-Byte-Snapshot-Config-MLX";
5772        let repo = "example/zero-byte-snapshot-config";
5773        let snapshot = hf_home
5774            .join("hub")
5775            .join("models--example--zero-byte-snapshot-config")
5776            .join("snapshots")
5777            .join("fixture");
5778        write_complete_mlx_snapshot(&snapshot);
5779        std::fs::write(snapshot.join("config.json"), b"").unwrap();
5780        // An empty managed directory leaves the snapshot as the only reuse
5781        // candidate.
5782        let model_dir = models.join(name);
5783        std::fs::create_dir_all(&model_dir).unwrap();
5784        let mut reg = UnifiedRegistry::new_empty(models);
5785        reg.register(mlx_schema("mlx/zero-byte-snapshot-config", name, repo));
5786        let recorder =
5787            std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
5788        let sink = ProgressSink::new(recorder.clone());
5789
5790        let result = reg
5791            .ensure_local_with_progress("mlx/zero-byte-snapshot-config", &sink)
5792            .await;
5793
5794        let events = recorder.events();
5795        assert!(
5796            matches!(events.first(), Some(DownloadEvent::Started { .. })),
5797            "a snapshot with a zero-byte config.json must start an acquisition, not be reused; events: {events:?}"
5798        );
5799        assert_eq!(started_count(&events), 1);
5800        assert!(result.is_err());
5801    }
5802
5803    #[cfg(unix)]
5804    #[tokio::test]
5805    async fn an_oversized_installed_model_is_reused_below_the_disk_threshold() {
5806        let _environment = crate::openrouter::test_environment_scope_async().await;
5807        let tmp = TempDir::new().unwrap();
5808        let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5809        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5810        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5811        let models = tmp.path().join("models");
5812        let mut schema = mlx_schema(
5813            "mlx/reuse-oversized",
5814            "Reuse-Oversized-MLX",
5815            "example/reuse-oversized",
5816        );
5817        // One petabyte in MB: safely representable by the canonical catalog
5818        // identity while still exceeding any machine this test can run on.
5819        schema.cost.size_mb = Some(1_000_000_000);
5820        let mut reg = UnifiedRegistry::new_empty(models.clone());
5821        reg.register(schema);
5822        write_mlx_dir(&models, "Reuse-Oversized-MLX", "qwen3");
5823        let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5824        let sink = ProgressSink::new(recorder.clone());
5825
5826        let path = reg
5827            .ensure_local_with_progress("mlx/reuse-oversized", &sink)
5828            .await
5829            .unwrap();
5830
5831        assert_eq!(path, models.join("Reuse-Oversized-MLX"));
5832        assert!(recorder.events().is_empty());
5833    }
5834
5835    #[tokio::test]
5836    async fn a_missing_shard_starts_acquisition_exactly_once_after_the_preflight() {
5837        let _environment = crate::openrouter::test_environment_scope_async().await;
5838        let tmp = TempDir::new().unwrap();
5839        let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5840        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5841        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5842        let models = tmp.path().join("models");
5843        let model_dir = models.join("Missing-Shard-MLX");
5844        std::fs::create_dir_all(&model_dir).unwrap();
5845        std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
5846        std::fs::write(
5847            model_dir.join("model.safetensors.index.json"),
5848            r#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#,
5849        )
5850        .unwrap();
5851        std::fs::write(model_dir.join("model-00002-of-00002.safetensors"), b"two").unwrap();
5852        let mut reg = UnifiedRegistry::new_empty(models);
5853        reg.register(mlx_schema(
5854            "mlx/missing-shard-lifecycle",
5855            "Missing-Shard-MLX",
5856            "example/missing-shard",
5857        ));
5858        let recorder =
5859            std::sync::Arc::new(AcquisitionRecorder::replacing_dir_on_started(model_dir));
5860        let sink = ProgressSink::new(recorder.clone());
5861
5862        assert!(reg
5863            .ensure_local_with_progress("mlx/missing-shard-lifecycle", &sink)
5864            .await
5865            .is_err());
5866        let events = recorder.events();
5867        assert!(matches!(
5868            events.first(),
5869            Some(DownloadEvent::Started { .. })
5870        ));
5871        assert_eq!(started_count(&events), 1);
5872    }
5873
5874    #[tokio::test]
5875    async fn a_flux_dir_missing_its_auxiliary_tokenizer_keeps_the_lifecycle() {
5876        let _environment = crate::openrouter::test_environment_scope_async().await;
5877        let tmp = TempDir::new().unwrap();
5878        let _hf_home = ScopedEnvVar::set("HF_HOME", &tmp.path().join("hf-home"));
5879        let _hub = ScopedEnvVar::unset("HF_HUB_CACHE");
5880        let _legacy_hub = ScopedEnvVar::unset("HUGGINGFACE_HUB_CACHE");
5881        let models = tmp.path().join("models");
5882        let name = "Flux-1.lite-8B-MLX-Q4";
5883        write_mlx_dir(&models, name, "flux");
5884        std::fs::write(models.join(name).join("tokenizer_2"), b"not a directory").unwrap();
5885        let mut reg = UnifiedRegistry::new_empty(models);
5886        reg.register(mlx_schema(
5887            "mlx/flux-missing-aux",
5888            name,
5889            "mlx-community/Flux-1.lite-8B-MLX-Q4",
5890        ));
5891        let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5892        let sink = ProgressSink::new(recorder.clone());
5893
5894        assert!(reg
5895            .ensure_local_with_progress("mlx/flux-missing-aux", &sink)
5896            .await
5897            .is_err());
5898        let events = recorder.events();
5899        assert!(matches!(
5900            events.first(),
5901            Some(DownloadEvent::Started { .. })
5902        ));
5903        assert_eq!(started_count(&events), 1);
5904    }
5905
5906    #[tokio::test]
5907    async fn force_bypasses_reuse() {
5908        let _environment = crate::openrouter::test_environment_scope_async().await;
5909        let tmp = TempDir::new().unwrap();
5910        let models = tmp.path().join("models");
5911        let name = "Force-Local";
5912        let model_dir = models.join(name);
5913        std::fs::create_dir_all(&model_dir).unwrap();
5914        std::fs::write(model_dir.join("model.gguf"), b"weights").unwrap();
5915        std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
5916        let mut reg = UnifiedRegistry::new_empty(models);
5917        reg.register(test_generate_schema(
5918            "local/force-lifecycle",
5919            name,
5920            ModelSource::Local {
5921                hf_repo: String::new(),
5922                hf_filename: "model.gguf".into(),
5923                tokenizer_repo: String::new(),
5924            },
5925        ));
5926        let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5927        let sink = ProgressSink::new(recorder.clone());
5928
5929        assert!(reg
5930            .acquire_and_ensure("local/force-lifecycle", &sink, true, None)
5931            .await
5932            .is_err());
5933        let events = recorder.events();
5934        assert!(matches!(
5935            events.first(),
5936            Some(DownloadEvent::Started { .. })
5937        ));
5938        assert_eq!(started_count(&events), 1);
5939    }
5940
5941    #[tokio::test]
5942    async fn staged_pulls_keep_the_lifecycle() {
5943        let _environment = crate::openrouter::test_environment_scope_async().await;
5944        let tmp = TempDir::new().unwrap();
5945        let models = tmp.path().join("models");
5946        let canonical = models.join("Staged-Local");
5947        std::fs::create_dir_all(&canonical).unwrap();
5948        std::fs::write(canonical.join("model.gguf"), b"canonical weights").unwrap();
5949        std::fs::write(canonical.join("tokenizer.json"), b"{}").unwrap();
5950        let mut reg = UnifiedRegistry::new_empty(models);
5951        reg.register(test_generate_schema(
5952            "local/staged-lifecycle",
5953            "Staged-Local",
5954            ModelSource::Local {
5955                hf_repo: String::new(),
5956                hf_filename: "model.gguf".into(),
5957                tokenizer_repo: String::new(),
5958            },
5959        ));
5960        let staging = tmp.path().join("staging");
5961        std::fs::create_dir_all(&staging).unwrap();
5962        std::fs::write(staging.join("model.gguf"), b"weights").unwrap();
5963        std::fs::write(staging.join("tokenizer.json"), b"{}").unwrap();
5964        let recorder = std::sync::Arc::new(AcquisitionRecorder::default());
5965        let sink = ProgressSink::new(recorder.clone());
5966
5967        let path = reg
5968            .acquire_and_ensure("local/staged-lifecycle", &sink, false, Some(&staging))
5969            .await
5970            .unwrap();
5971        assert_eq!(path, staging);
5972        let events = recorder.events();
5973        assert!(matches!(
5974            events.first(),
5975            Some(DownloadEvent::Started { .. })
5976        ));
5977        assert_eq!(started_count(&events), 1);
5978    }
5979
5980    #[test]
5981    fn synthesize_local_schema_classifies_by_name_and_arch() {
5982        let tmp = TempDir::new().unwrap();
5983        let root = tmp.path();
5984
5985        write_mlx_dir(root, "MyCustom-Qwen3-7B", "qwen3");
5986        let gen = synthesize_local_schema("MyCustom-Qwen3-7B", &root.join("MyCustom-Qwen3-7B"))
5987            .expect("text LLM should be recognized");
5988        assert_eq!(
5989            gen.capabilities,
5990            vec![
5991                ModelCapability::Generate,
5992                ModelCapability::Code,
5993                ModelCapability::Reasoning
5994            ]
5995        );
5996        assert_eq!(gen.context_length, 40_960);
5997        assert_eq!(gen.provider, "local");
5998        assert!(matches!(gen.source, ModelSource::Mlx { .. }));
5999
6000        write_mlx_dir(root, "Some-Embedding-0.6B", "qwen3");
6001        let emb = synthesize_local_schema("Some-Embedding-0.6B", &root.join("Some-Embedding-0.6B"))
6002            .expect("embedding model recognized");
6003        assert_eq!(emb.capabilities, vec![ModelCapability::Embed]);
6004
6005        // Unknown architecture: refuse rather than guess.
6006        write_mlx_dir(root, "Mystery-Net", "some_unknown_arch");
6007        assert!(synthesize_local_schema("Mystery-Net", &root.join("Mystery-Net")).is_none());
6008
6009        // Speech/vision/etc. names are skipped even with a valid LLM config.
6010        write_mlx_dir(root, "silero-vad-v6-mlx", "qwen3");
6011        assert!(
6012            synthesize_local_schema("silero-vad-v6-mlx", &root.join("silero-vad-v6-mlx")).is_none()
6013        );
6014
6015        // A bare directory with no weights is not a model.
6016        std::fs::create_dir_all(root.join("empty")).unwrap();
6017        assert!(synthesize_local_schema("empty", &root.join("empty")).is_none());
6018    }
6019
6020    /// A GGUF directory found by the scan carries no `hf_repo` — its weights
6021    /// are whatever the user put there. When the layout is not what the Candle
6022    /// backend reads, CAR used to attempt a download against the empty repo and
6023    /// report whatever `https://huggingface.co//…` returned, which explains
6024    /// nothing about the actual problem.
6025    #[test]
6026    fn a_scanned_gguf_directory_diagnoses_instead_of_downloading_from_nowhere() {
6027        let _environment = crate::openrouter::test_environment_scope();
6028        let tmp = TempDir::new().unwrap();
6029        let models = tmp.path().join("models");
6030        let dir = models.join("Dropped-In-Llama");
6031        std::fs::create_dir_all(&dir).unwrap();
6032        // A real user drop-in: the file keeps the name it was published under.
6033        std::fs::write(dir.join("Llama-3-8B-Q4_K_M.gguf"), b"weights").unwrap();
6034
6035        // Constructed outside the runtime: `new_with_state_root` ends in
6036        // `refresh_availability`, which blocks.
6037        let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6038        let err = tokio::runtime::Runtime::new()
6039            .unwrap()
6040            .block_on(reg.ensure_local("Dropped-In-Llama"))
6041            .expect_err("an unloadable layout must not report success");
6042        let err = err.to_string();
6043
6044        assert!(err.contains("model.gguf"), "must name what it reads: {err}");
6045        assert!(
6046            err.contains("tokenizer.json"),
6047            "must name the missing tokenizer too: {err}"
6048        );
6049        assert!(
6050            !err.contains("huggingface.co//"),
6051            "must not have tried to fetch from an empty repo: {err}"
6052        );
6053    }
6054
6055    #[test]
6056    fn discovery_registers_uncatalogued_local_model() {
6057        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
6058        // which WRITES the process-global gateway observation. Full reasoning
6059        // on `TestRegistry` in this crate; family in
6060        // docs/solutions/process-global-state-in-tests.md.
6061        let _environment = crate::openrouter::test_environment_scope();
6062        let tmp = TempDir::new().unwrap();
6063        let models = tmp.path().join("models");
6064        std::fs::create_dir_all(&models).unwrap();
6065        write_mlx_dir(&models, "Totally-Custom-Llama-3B", "llama");
6066
6067        let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6068        let found = reg
6069            .list()
6070            .into_iter()
6071            .find(|m| m.name == "Totally-Custom-Llama-3B");
6072        assert!(
6073            found.is_some(),
6074            "uncatalogued on-disk model should be registered"
6075        );
6076        assert!(found.unwrap().tags.iter().any(|t| t == "auto-discovered"));
6077    }
6078
6079    #[test]
6080    fn explicit_user_row_is_not_pruned_by_auto_discovery_tag() {
6081        let _environment = crate::openrouter::test_environment_scope();
6082        let tmp = TempDir::new().unwrap();
6083        let models = tmp.path().join("models");
6084        let model_dir = models.join("Explicit-Custom-Llama");
6085        write_mlx_dir(&models, "Explicit-Custom-Llama", "llama");
6086
6087        let mut registry = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
6088        let model_id = "local/explicit-custom-llama";
6089        let schema = registry.get(model_id).cloned().unwrap();
6090        assert!(schema.tags.iter().any(|tag| tag == "auto-discovered"));
6091
6092        registry.register_user_model(schema);
6093        std::fs::remove_dir_all(model_dir).unwrap();
6094        registry.prune_missing_on_disk_models();
6095
6096        assert!(
6097            registry.get(model_id).is_some(),
6098            "explicit registration provenance must outrank a user-controlled tag"
6099        );
6100    }
6101
6102    /// A directory whose synthesized id collides with a row something else
6103    /// registered must leave that row alone: it must neither replace it nor
6104    /// mark it disposable, or deleting the directory later erases the
6105    /// registered model (car#1650).
6106    #[test]
6107    fn colliding_directory_does_not_claim_a_registered_row() {
6108        let tmp = TempDir::new().unwrap();
6109        let models = tmp.path().join("models");
6110        write_mlx_dir(&models, "Collide-User-Llama", "llama");
6111        write_mlx_dir(&models, "Collide-Project-Llama", "llama");
6112        let mut registry = UnifiedRegistry::new_empty(models.clone());
6113
6114        let mut user =
6115            synthesize_local_schema("Collide-User-Llama", &models.join("Collide-User-Llama"))
6116                .unwrap();
6117        user.name = "Configured-Elsewhere-User".into();
6118        let user_id = user.id.clone();
6119        registry.register_user_model(user);
6120
6121        let mut project = synthesize_local_schema(
6122            "Collide-Project-Llama",
6123            &models.join("Collide-Project-Llama"),
6124        )
6125        .unwrap();
6126        project.name = "Configured-Elsewhere-Project".into();
6127        let project_id = project.id.clone();
6128        assert!(registry.register_project_model(project));
6129
6130        registry.discover_on_disk_models();
6131        assert_eq!(
6132            registry.get(&user_id).map(|m| m.name.as_str()),
6133            Some("Configured-Elsewhere-User"),
6134            "discovery must not replace a registered row"
6135        );
6136
6137        std::fs::remove_dir_all(models.join("Collide-User-Llama")).unwrap();
6138        std::fs::remove_dir_all(models.join("Collide-Project-Llama")).unwrap();
6139        registry.prune_missing_on_disk_models();
6140
6141        assert!(
6142            registry.get(&user_id).is_some(),
6143            "removing a colliding directory must not erase a user row"
6144        );
6145        assert!(
6146            registry.get(&project_id).is_some(),
6147            "removing a colliding directory must not erase a project row"
6148        );
6149    }
6150
6151    #[test]
6152    fn signed_catalog_cannot_shadow_builtin_exact_id() {
6153        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
6154        // which WRITES the process-global gateway observation. Full reasoning
6155        // on `TestRegistry` in this crate; family in
6156        // docs/solutions/process-global-state-in-tests.md.
6157        let _environment = crate::openrouter::test_environment_scope();
6158        // A signature authenticates the catalog publisher, but it does not
6159        // authorize changing a project-owned exact id compiled into this CAR
6160        // build. An additive signed row may not shadow a builtin.
6161        let tmp = TempDir::new().unwrap();
6162        let models_dir = tmp.path().join("models");
6163
6164        let builtin = builtin_catalog();
6165        let mut overriding = builtin.first().expect("a built-in model").clone();
6166        let target_id = overriding.id.clone();
6167        overriding.name = "REPLACED-BY-CATALOG".into();
6168
6169        let (verified, public_key) = crate::catalog::signed_test_catalog(
6170            crate::catalog::CatalogDoc {
6171                revoked: Vec::new(),
6172                version: 1,
6173                models: vec![overriding],
6174            },
6175            51,
6176        );
6177        crate::catalog::save_verified(&crate::catalog::cache_path(tmp.path()), &verified).unwrap();
6178
6179        // new() loads the verified cache additively after the built-ins.
6180        let reg = UnifiedRegistry::new_with_catalog_public_key(
6181            tmp.path().to_path_buf(),
6182            models_dir,
6183            Some(public_key.as_str()),
6184        );
6185        assert_eq!(
6186            reg.get(&target_id).map(|m| m.name.as_str()),
6187            Some(builtin.first().unwrap().name.as_str()),
6188            "a signed cache row must not replace a builtin exact id"
6189        );
6190    }
6191
6192    #[test]
6193    fn user_model_cannot_shadow_project_owned_exact_id() {
6194        let mut reg = test_registry();
6195        let original = builtin_catalog().first().expect("a builtin").clone();
6196        let mut forged = original.clone();
6197        forged.name = "USER-SHADOW".into();
6198
6199        reg.register_user_model(forged);
6200
6201        assert_eq!(
6202            reg.get(&original.id).map(|model| model.name.as_str()),
6203            Some(original.name.as_str()),
6204            "a user row must not replace a project-owned exact id"
6205        );
6206    }
6207
6208    #[test]
6209    fn legacy_unsigned_catalog_cache_cannot_replace_builtin() {
6210        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
6211        // which WRITES the process-global gateway observation. Full reasoning
6212        // on `TestRegistry` in this crate; family in
6213        // docs/solutions/process-global-state-in-tests.md.
6214        let _environment = crate::openrouter::test_environment_scope();
6215        let tmp = TempDir::new().unwrap();
6216        let models_dir = tmp.path().join("models");
6217        let builtin = builtin_catalog();
6218        let original = builtin.first().expect("a built-in model");
6219        let mut forged = original.clone();
6220        forged.name = "FORGED-UNSIGNED-CATALOG".into();
6221        let path = crate::catalog::cache_path(tmp.path());
6222        std::fs::write(
6223            &path,
6224            serde_json::to_vec_pretty(&crate::catalog::CatalogDoc {
6225                revoked: Vec::new(),
6226                version: u64::MAX,
6227                models: vec![forged],
6228            })
6229            .unwrap(),
6230        )
6231        .unwrap();
6232
6233        let reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models_dir);
6234        assert_eq!(
6235            reg.get(&original.id).map(|model| model.name.as_str()),
6236            Some(original.name.as_str()),
6237            "legacy unsigned cache JSON must fail closed and preserve the built-in"
6238        );
6239    }
6240
6241    #[test]
6242    fn tampered_signed_managed_row_preserves_builtin() {
6243        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
6244        // which WRITES the process-global gateway observation. Full reasoning
6245        // on `TestRegistry` in this crate; family in
6246        // docs/solutions/process-global-state-in-tests.md.
6247        let _environment = crate::openrouter::test_environment_scope();
6248        let tmp = TempDir::new().unwrap();
6249        let models_dir = tmp.path().join("models");
6250        let original = builtin_catalog()
6251            .into_iter()
6252            .find(|model| model.id == "parslee/openrouter/frontier-general")
6253            .expect("managed frontier alias");
6254        let mut forged = original.clone();
6255        forged.name = "SIGNED-THEN-TAMPERED-MANAGED".into();
6256        let (verified, public_key) = crate::catalog::signed_test_catalog(
6257            crate::catalog::CatalogDoc {
6258                revoked: Vec::new(),
6259                version: 9,
6260                models: vec![forged],
6261            },
6262            52,
6263        );
6264        let path = crate::catalog::cache_path(tmp.path());
6265        crate::catalog::save_verified(&path, &verified).unwrap();
6266        let cache = std::fs::read_to_string(&path)
6267            .unwrap()
6268            .replace("SIGNED-THEN-TAMPERED-MANAGED", "ATTACKER-MUTATION");
6269        std::fs::write(&path, cache).unwrap();
6270
6271        let reg = UnifiedRegistry::new_with_catalog_public_key(
6272            tmp.path().to_path_buf(),
6273            models_dir,
6274            Some(public_key.as_str()),
6275        );
6276        assert_eq!(
6277            reg.get(&original.id).map(|model| model.name.as_str()),
6278            Some(original.name.as_str()),
6279            "a tampered same-id managed row must fail verification and preserve the builtin"
6280        );
6281    }
6282
6283    #[test]
6284    fn builtin_catalog_loads() {
6285        let reg = test_registry();
6286        let all = reg.list();
6287        assert_eq!(all.len(), builtin_catalog().len());
6288    }
6289
6290    #[test]
6291    fn subscription_billing_lookup_uses_exact_or_implicit_latest_ids() {
6292        let codex_ids: Vec<_> = builtin_catalog()
6293            .into_iter()
6294            .filter(|model| model.id.starts_with("openai-codex/"))
6295            .map(|model| model.id)
6296            .collect();
6297        assert!(!codex_ids.is_empty());
6298        for id in codex_ids {
6299            assert!(is_builtin_subscription_billed(&id), "{id}");
6300            if let Some(untagged) = id.strip_suffix(":latest") {
6301                assert!(is_builtin_subscription_billed(untagged), "{untagged}");
6302            }
6303        }
6304        assert!(!is_builtin_subscription_billed("parslee/reasoning"));
6305        assert!(!is_builtin_subscription_billed("openai/gpt-5.5-2026-04-23"));
6306        assert!(!is_builtin_subscription_billed("unknown/not-a-model"));
6307    }
6308
6309    #[test]
6310    fn shipped_supervised_vllm_models_use_the_managed_source_contract() {
6311        let managed = builtin_catalog()
6312            .into_iter()
6313            .filter(|model| model.id.starts_with("vllm-mlx/"))
6314            .collect::<Vec<_>>();
6315        assert_eq!(managed.len(), 8);
6316        assert!(managed.iter().all(ModelSchema::is_car_managed_vllm_mlx));
6317        assert!(managed.iter().all(ModelSchema::downloads_weights));
6318    }
6319
6320    /// #137: a model tagged `requires-mlx-vlm` must report
6321    /// `available: true` if and only if `mlx_vlm_cli::is_available()`
6322    /// returns true. Without this, registry consumers (FFI
6323    /// `listModelsUnified`, the tray Models submenu, agent routing)
6324    /// see a model as available, the user picks it, and inference
6325    /// bails with `mlx-vlm CLI not found on PATH`.
6326    ///
6327    /// The probe is environmental — runs the same check on the host
6328    /// the test executes on. CI usually doesn't have `mlx_vlm`
6329    /// installed → expected unavailable; a dev box with it installed
6330    /// → expected available. Either way, the registry tracks the
6331    /// runtime probe.
6332    #[test]
6333    fn mlx_vlm_models_reflect_runtime_availability() {
6334        let reg = test_registry();
6335        let mlx_vlm_models: Vec<&ModelSchema> = reg
6336            .list()
6337            .into_iter()
6338            .filter(|m| m.tags.iter().any(|t| t == "requires-mlx-vlm"))
6339            .collect();
6340        assert!(
6341            !mlx_vlm_models.is_empty(),
6342            "catalog should contain at least one model tagged \
6343             `requires-mlx-vlm` — otherwise this regression has \
6344             nothing to guard"
6345        );
6346
6347        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6348        let expected = crate::backend::mlx_vlm_cli::is_available();
6349        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6350        let expected = false;
6351
6352        for m in mlx_vlm_models {
6353            assert_eq!(
6354                m.available, expected,
6355                "model {} `available` field should reflect \
6356                 mlx_vlm CLI presence (expected {expected}, got {})",
6357                m.id, m.available
6358            );
6359        }
6360    }
6361
6362    /// F1 (Parslee-ai/car#231 §7.1): MLX models must report
6363    /// `available: false` on any non-Apple-Silicon-with-MLX build target.
6364    /// Without this, the Windows / Linux / Intel-Mac router happily
6365    /// adds MLX entries to fallback chains and inference fails at
6366    /// dispatch with "model not found", producing a 7-deep cascade
6367    /// of useless errors on a fresh install.
6368    ///
6369    /// The test runs on every platform. On macOS arm64 (default-features),
6370    /// at least one MLX model with an `hf_repo` should report available;
6371    /// on Linux / Windows / Intel-Mac / `car_skip_mlx`, every plain MLX
6372    /// model must report unavailable.
6373    #[test]
6374    fn mlx_models_unavailable_on_non_mlx_targets() {
6375        let reg = test_registry();
6376        let mlx_models: Vec<&ModelSchema> = reg
6377            .list()
6378            .into_iter()
6379            .filter(|m| {
6380                m.is_mlx()
6381                    // Exclude `requires-mlx-vlm` and `speech` — those have
6382                    // their own availability logic (mlx_vlm CLI probe,
6383                    // speech_mlx_available). The plain MLX models are
6384                    // what §7.1 surfaced as broken on Windows.
6385                    && !m.tags.iter().any(|t| t == "requires-mlx-vlm")
6386                    && !m.tags.contains(&"speech".to_string())
6387            })
6388            .collect();
6389        assert!(
6390            !mlx_models.is_empty(),
6391            "catalog should contain at least one plain MLX model — \
6392             otherwise this F1 regression guard has nothing to guard"
6393        );
6394
6395        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6396        {
6397            // On a real MLX target, models with an `hf_repo` should be
6398            // marked available (the `ensure_local()` lazy-download path).
6399            // At least one must qualify.
6400            let any_available = mlx_models.iter().any(|m| m.available);
6401            assert!(
6402                any_available,
6403                "on macOS arm64 with MLX enabled, at least one plain MLX \
6404                 model with hf_repo should be available — none were"
6405            );
6406        }
6407        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6408        {
6409            // On every other target, MLX models cannot execute, so the
6410            // registry must mark them all unavailable.
6411            for m in &mlx_models {
6412                assert!(
6413                    !m.available,
6414                    "MLX model {} is marked available on a non-MLX target — \
6415                     the adaptive router will add it to fallback chains \
6416                     and dispatch will fail (Parslee-ai/car#231 §7.1)",
6417                    m.id
6418                );
6419            }
6420        }
6421    }
6422
6423    /// Embedded JSON must parse cleanly — if it doesn't, the runtime
6424    /// would panic on first registry load. Catch it in CI instead.
6425    /// The `provider/name[:tag]` form `InferenceModelIdentity::resolved_model_id`
6426    /// documents for built-in ids: each part is `[A-Za-z0-9][A-Za-z0-9._-]*`.
6427    fn is_builtin_model_id_form(id: &str) -> bool {
6428        fn part(s: &str) -> bool {
6429            let mut chars = s.chars();
6430            chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
6431                && chars.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
6432        }
6433        let (body, tag) = match id.split_once(':') {
6434            Some((body, tag)) => (body, Some(tag)),
6435            None => (id, None),
6436        };
6437        let Some((provider, name)) = body.split_once('/') else {
6438            return false;
6439        };
6440        part(provider) && part(name) && tag.is_none_or(part)
6441    }
6442
6443    #[test]
6444    fn builtin_model_ids_follow_the_resolved_model_id_contract() {
6445        for good in [
6446            "anthropic/claude-opus-4-8",
6447            "openai/gpt-5.5-2026-04-23",
6448            "qwen/qwen3-4b:q4_k_m",
6449        ] {
6450            assert!(is_builtin_model_id_form(good), "{good} should pass");
6451        }
6452        for bad in [
6453            "",
6454            "gpt-5.5",
6455            "/name",
6456            "openai/",
6457            "-openai/x",
6458            "openai/-x",
6459            "open ai/x",
6460            "openai/x y",
6461            "openai/é",
6462            "parslee/openrouter/open-fast",
6463            "openai/x:",
6464            "openai/x:-a",
6465            "openai/x:a b",
6466            "openai/x:a:b",
6467            "openai/x:a/b",
6468        ] {
6469            assert!(!is_builtin_model_id_form(bad), "{bad:?} should fail");
6470        }
6471        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6472        let off: Vec<&str> = catalog
6473            .iter()
6474            .map(|m| m.id.as_str())
6475            .filter(|id| !is_builtin_model_id_form(id))
6476            .collect();
6477        assert!(off.is_empty(), "built-in ids outside the contract: {off:?}");
6478    }
6479
6480    #[test]
6481    fn builtin_catalog_json_parses() {
6482        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON)
6483            .expect("builtin_catalog.json must be valid ModelSchema array");
6484        assert!(
6485            !catalog.is_empty(),
6486            "embedded catalog has no entries — that's almost certainly wrong"
6487        );
6488
6489        let mut seen = std::collections::HashSet::new();
6490        for entry in &catalog {
6491            assert!(
6492                seen.insert(entry.id.clone()),
6493                "duplicate id in builtin_catalog.json: {}",
6494                entry.id
6495            );
6496        }
6497    }
6498
6499    #[test]
6500    fn codex_subscription_row_has_pinnable_tool_proposal_identity() {
6501        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6502        let row = catalog
6503            .iter()
6504            .find(|model| model.id == "openai/gpt-5.6-sol:high")
6505            .expect("catalog publishes the Codex subscription identity");
6506        assert_eq!(row.name, "gpt-5.6-sol:high");
6507        assert!(matches!(
6508            &row.source,
6509            ModelSource::CodexCli { model } if model == "gpt-5.6-sol:high"
6510        ));
6511        assert!(row.has_capability(ModelCapability::Generate));
6512        assert!(row.has_capability(ModelCapability::Reasoning));
6513        assert!(row.has_capability(ModelCapability::ToolUse));
6514        assert!(row.has_capability(ModelCapability::MultiToolCall));
6515        // Inline images reach the model as `codex exec --image` files.
6516        assert!(row.has_capability(ModelCapability::Vision));
6517        assert!(!row.tags.iter().any(|tag| tag == "text-only"));
6518        assert_eq!(row.supported_params, vec![GenerateParam::MaxTokens]);
6519        assert!(!row.downloads_weights());
6520    }
6521
6522    #[test]
6523    fn codex_subscription_rows_publish_the_gpt_5_6_family() {
6524        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6525        assert_eq!(
6526            catalog
6527                .iter()
6528                .filter(|model| model.provider == "openai-codex")
6529                .count(),
6530            6,
6531            "the explicit checks below must cover every openai-codex row"
6532        );
6533        for id in [
6534            "openai-codex/gpt-5.6-sol:latest",
6535            "openai-codex/gpt-5.6-sol:high",
6536            "openai-codex/gpt-5.6-luna:latest",
6537            "openai-codex/gpt-5.6-luna:high",
6538            "openai-codex/gpt-5.6-terra:latest",
6539            "openai-codex/gpt-5.6-terra:high",
6540        ] {
6541            let row = catalog
6542                .iter()
6543                .find(|model| model.id == id)
6544                .unwrap_or_else(|| panic!("missing {id}"));
6545            assert_eq!(row.provider, "openai-codex", "{id}");
6546            assert!(
6547                matches!(&row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::ChatGptSubscription {}, .. } if provider == "openai-codex"),
6548                "{id}"
6549            );
6550            assert!(row.is_subscription_billed(), "{id}");
6551            assert!(
6552                row.cost.input_per_mtok.is_none() && row.cost.output_per_mtok.is_none(),
6553                "{id} must carry no dollar price"
6554            );
6555            assert!(row.tags.iter().any(|tag| tag == "subscription"), "{id}");
6556            assert!(
6557                row.cost
6558                    .estimated_usd_bounded(Some(1_000), 1_000, 1_000, 0, 0)
6559                    .is_none(),
6560                "{id} must not produce an approximate dollar cost"
6561            );
6562            let mut accidentally_priced = row.clone();
6563            accidentally_priced.cost.input_per_mtok = Some(1.0);
6564            accidentally_priced.cost.output_per_mtok = Some(1.0);
6565            assert!(
6566                crate::scoreboard_price_model(&accidentally_priced).is_none(),
6567                "{id} must stay out of the dollar scoreboard"
6568            );
6569            assert!(row.has_capability(ModelCapability::ToolUse), "{id}");
6570            // A turn's images go to the Codex backend as `input_image` parts.
6571            assert!(row.has_capability(ModelCapability::Vision), "{id}");
6572            assert_eq!(row.name.ends_with(":high"), id.ends_with(":high"), "{id}");
6573        }
6574        let cli = catalog
6575            .iter()
6576            .find(|model| model.id == "openai/gpt-5.6-sol:high")
6577            .unwrap();
6578        assert!(
6579            matches!(&cli.source, ModelSource::CodexCli { .. }),
6580            "the Codex CLI row stays as it was"
6581        );
6582        assert!(!cli.is_subscription_billed());
6583        let api = catalog
6584            .iter()
6585            .find(|model| model.id == "openai/gpt-5.6-sol:latest")
6586            .unwrap();
6587        assert!(
6588            matches!(&api.source, ModelSource::RemoteApi { .. }),
6589            "the API-key row stays as it was"
6590        );
6591        assert!(!api.is_subscription_billed());
6592        let parslee = catalog
6593            .iter()
6594            .find(|model| model.id == "parslee/fast")
6595            .expect("catalog publishes a Parslee row");
6596        assert!(!parslee.is_subscription_billed());
6597    }
6598
6599    #[test]
6600    fn codex_rows_unavailable_without_sign_in_carry_the_sign_in_reason() {
6601        let _environment = crate::openrouter::test_environment_scope();
6602        remember_codex_sign_in(false);
6603
6604        let tmp = TempDir::new().unwrap();
6605        let mut registry = UnifiedRegistry::new_with_state_root(
6606            tmp.path().to_path_buf(),
6607            tmp.path().join("models"),
6608        );
6609        let expected_ids = [
6610            "openai-codex/gpt-5.6-sol:latest",
6611            "openai-codex/gpt-5.6-sol:high",
6612            "openai-codex/gpt-5.6-luna:latest",
6613            "openai-codex/gpt-5.6-luna:high",
6614            "openai-codex/gpt-5.6-terra:latest",
6615            "openai-codex/gpt-5.6-terra:high",
6616        ];
6617
6618        for id in expected_ids {
6619            let info = ModelInfo::from(
6620                registry
6621                    .get(id)
6622                    .unwrap_or_else(|| panic!("missing builtin Codex row {id}")),
6623            );
6624            assert!(!info.available, "{id} must be unavailable while signed out");
6625            let reason = model_unavailable_reason(&info).expect("signed-out Codex reason");
6626            assert_eq!(reason, crate::schema::OPENAI_CODEX_SIGN_IN_HINT);
6627            assert_eq!(
6628                serde_json::to_value(&info).unwrap()["unavailable_reason"],
6629                reason
6630            );
6631            assert!(reason.contains("car auth login --provider openai-codex"));
6632            assert!(reason.contains("Settings → AI accounts"));
6633        }
6634
6635        remember_codex_sign_in(true);
6636        registry.refresh_availability();
6637
6638        for id in expected_ids {
6639            let info = ModelInfo::from(registry.get(id).unwrap());
6640            assert!(info.available, "{id} must be available while signed in");
6641            assert_eq!(model_unavailable_reason(&info), None, "{id}");
6642            assert!(serde_json::to_value(&info).unwrap()["unavailable_reason"].is_null());
6643        }
6644
6645        remember_codex_sign_in(false);
6646    }
6647
6648    #[test]
6649    fn work_context_availability_is_independent_of_other_org_rejection() {
6650        let _environment = crate::openrouter::test_environment_scope();
6651        crate::parslee_credential::clear_credential_rejected();
6652        let tmp = TempDir::new().unwrap();
6653        let mut parslee = UnifiedRegistry::new_with_state_root(
6654            tmp.path().to_path_buf(),
6655            tmp.path().join("models"),
6656        );
6657        let mut flyexclusive = parslee.clone();
6658        crate::parslee_credential::note_credential_rejected();
6659        parslee.refresh_work_context_availability("https://authority.example", true);
6660        flyexclusive.refresh_work_context_availability("https://authority.example", false);
6661        let scoped_rows: Vec<_> = parslee.all().filter(|row| matches!(
6662            &row.source, ModelSource::Proprietary { provider, auth: ProprietaryAuth::OAuth2Pkce { .. }, .. }
6663                if provider == "parslee"
6664        )).collect();
6665        assert!(!scoped_rows.is_empty());
6666        for row in scoped_rows {
6667            assert!(row.available);
6668            assert!(!flyexclusive.get(&row.id).unwrap().available);
6669        }
6670        assert!(
6671            crate::parslee_credential::credential_rejected(),
6672            "scoped routing must not clear another context's observation"
6673        );
6674        crate::parslee_credential::clear_credential_rejected();
6675    }
6676
6677    #[test]
6678    fn codex_availability_leaves_openai_and_parslee_rows_alone() {
6679        let _environment = crate::openrouter::test_environment_scope();
6680        remember_codex_sign_in(false);
6681
6682        let tmp = TempDir::new().unwrap();
6683        let mut registry = UnifiedRegistry::new_with_state_root(
6684            tmp.path().to_path_buf(),
6685            tmp.path().join("models"),
6686        );
6687        registry.refresh_availability();
6688
6689        let unaffected_ids: Vec<String> = registry
6690            .list()
6691            .into_iter()
6692            .filter(|row| {
6693                matches!(
6694                    row.id.as_str(),
6695                    "openai/gpt-5.6-sol:latest" | "openai/gpt-5.6-sol:high"
6696                ) || row.id.starts_with("parslee/")
6697            })
6698            .map(|row| row.id.clone())
6699            .collect();
6700        assert!(unaffected_ids
6701            .iter()
6702            .any(|id| id == "openai/gpt-5.6-sol:latest"));
6703        assert!(unaffected_ids
6704            .iter()
6705            .any(|id| id == "openai/gpt-5.6-sol:high"));
6706        assert!(
6707            unaffected_ids.iter().any(|id| id.starts_with("parslee/")),
6708            "builtin catalog must contain Parslee rows"
6709        );
6710        let signed_out: std::collections::HashMap<String, bool> = unaffected_ids
6711            .iter()
6712            .map(|id| (id.clone(), registry.get(id).unwrap().available))
6713            .collect();
6714
6715        remember_codex_sign_in(true);
6716        registry.refresh_availability();
6717
6718        for id in unaffected_ids {
6719            assert_eq!(
6720                registry.get(&id).unwrap().available,
6721                signed_out[&id],
6722                "Codex sign-in must not change {id} availability"
6723            );
6724        }
6725
6726        remember_codex_sign_in(false);
6727    }
6728
6729    #[test]
6730    fn builtin_catalog_names_are_unique_for_codex_rows() {
6731        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6732        for row in catalog.iter().filter(|row| row.provider == "openai-codex") {
6733            let occurrences = catalog
6734                .iter()
6735                .filter(|candidate| candidate.name == row.name)
6736                .count();
6737            assert_eq!(
6738                occurrences, 1,
6739                "openai-codex name must be unique: {}",
6740                row.name
6741            );
6742        }
6743    }
6744
6745    /// The Apple FoundationModels row must advertise `ToolUse` and must NOT
6746    /// advertise `MultiToolCall`, and its context window must match the
6747    /// framework's real limit. All three were wrong or missing, and each
6748    /// failure was silent:
6749    ///
6750    /// - `AdaptiveRouter` pushes `ModelCapability::ToolUse` as a HARD required
6751    ///   capability whenever `has_tools` is set, so a row without it can never
6752    ///   be selected for a tool-calling route. Omitting it made the entire
6753    ///   `backend::foundation_models::generate_with_tools` path — and the Swift
6754    ///   shim's capture-and-return bridge behind it — unreachable through the
6755    ///   router, with no error anywhere: the model was simply never a candidate.
6756    /// - The same filter pushes `MultiToolCall` for multi-step prompts. The
6757    ///   bridge ends the turn on the first captured call, so at most one tool
6758    ///   call per turn is possible and claiming it would be a lie the router
6759    ///   would act on.
6760    /// - `SystemLanguageModel`'s context window is 4096 tokens, not 8192.
6761    ///   `build_context_for_model` sizes the assembly budget from this number,
6762    ///   so an inflated value overcommits the prompt and the framework rejects
6763    ///   the request at generation time.
6764    ///
6765    /// Pins all three so the catalog claim and the implementation stay in
6766    /// lockstep, in the same spirit as the in-process Qwen3 test below.
6767    #[test]
6768    fn apple_foundation_row_claims_single_tool_call_and_the_real_context_window() {
6769        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6770        let row = catalog
6771            .iter()
6772            .find(|model| model.id == "apple/foundation:default")
6773            .expect("catalog publishes the Apple FoundationModels row");
6774        assert!(matches!(
6775            &row.source,
6776            ModelSource::AppleFoundationModels { .. }
6777        ));
6778        assert!(row.has_capability(ModelCapability::Generate));
6779        assert!(
6780            row.has_capability(ModelCapability::ToolUse),
6781            "generate_with_tools exists, so the router must be able to pick this row for tool routes"
6782        );
6783        assert!(
6784            !row.has_capability(ModelCapability::MultiToolCall),
6785            "the capture sentinel ends the turn on the first tool call"
6786        );
6787        assert!(!row.has_capability(ModelCapability::Vision));
6788        assert_eq!(
6789            row.context_length, 4096,
6790            "SystemLanguageModel's context window is 4096 tokens"
6791        );
6792        assert!(!row.downloads_weights());
6793    }
6794
6795    /// The row gains `multi_tool_call` exactly where the host can do it.
6796    ///
6797    /// The catalog literal deliberately omits it, because macOS 26 yields one
6798    /// captured call per turn; macOS 27 yields every proposed call through the
6799    /// same unchanged shim. So the claim is host-dependent, and the registry
6800    /// is where that gets resolved — the same hook that already makes
6801    /// `available` host-dependent for this source.
6802    ///
6803    /// Pins the agreement rather than a platform: whatever
6804    /// `supports_parallel_tool_calls()` reports is what the live row must
6805    /// claim. A regression that upgrades the row on a host that cannot do it
6806    /// (or fails to upgrade one that can) fails here.
6807    #[test]
6808    fn apple_foundation_multi_tool_call_tracks_what_the_host_can_actually_do() {
6809        #[cfg(any(
6810            all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6811            all(target_os = "ios", target_arch = "aarch64")
6812        ))]
6813        {
6814            let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6815            let declared = catalog
6816                .iter()
6817                .find(|m| m.id == "apple/foundation:default")
6818                .expect("catalog publishes the Apple FoundationModels row");
6819            assert!(
6820                !declared.has_capability(ModelCapability::MultiToolCall),
6821                "the catalog literal must stay conservative; the host decides"
6822            );
6823
6824            // A temp models dir keeps this off the developer's real ~/.car.
6825            let models_dir = tempfile::tempdir().unwrap();
6826            let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
6827            // expect(), not a silent early return: the row comes from the
6828            // builtin catalog and is therefore always present here, so a
6829            // missing one is a real regression rather than a reason to skip.
6830            // A test that can quietly pass by doing nothing is the failure
6831            // mode this whole row keeps hitting.
6832            let live = registry
6833                .models
6834                .get("apple/foundation:default")
6835                .expect("the builtin catalog row must survive into the live registry");
6836            let host_can = crate::backend::foundation_models::supports_parallel_tool_calls();
6837            assert_eq!(
6838                live.capabilities.contains(&ModelCapability::MultiToolCall),
6839                host_can,
6840                "live row must claim multi_tool_call iff the host supports it"
6841            );
6842        }
6843    }
6844
6845    /// The row gains `vision` exactly where images can actually be served.
6846    ///
6847    /// Stricter than the `multi_tool_call` rule, and deliberately so. That one
6848    /// only has to be truthful; this one also has to be *serviceable*, because
6849    /// `AdaptiveRouter` treats vision as a hard required capability and will
6850    /// hand image requests to whatever claims it. A row that advertised vision
6851    /// without `generate_with_images` behind it would be picked and then fail
6852    /// at generation — the same shape as the 8192-vs-4096 context bug, where
6853    /// the claim outran what the backend could do.
6854    ///
6855    /// Both sides read the same probe, so they cannot drift apart.
6856    #[test]
6857    fn apple_foundation_claims_vision_only_where_images_can_be_served() {
6858        #[cfg(any(
6859            all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6860            all(target_os = "ios", target_arch = "aarch64")
6861        ))]
6862        {
6863            let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6864            let declared = catalog
6865                .iter()
6866                .find(|m| m.id == "apple/foundation:default")
6867                .expect("catalog publishes the Apple FoundationModels row");
6868            assert!(
6869                !declared.has_capability(ModelCapability::Vision),
6870                "the catalog literal must stay conservative; the device decides"
6871            );
6872
6873            let models_dir = tempfile::tempdir().unwrap();
6874            let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
6875            let live = registry
6876                .models
6877                .get("apple/foundation:default")
6878                .expect("the builtin catalog row must survive into the live registry");
6879
6880            assert_eq!(
6881                live.capabilities.contains(&ModelCapability::Vision),
6882                crate::backend::foundation_models::supports_vision(),
6883                "the live row must claim vision iff this device's model accepts images"
6884            );
6885        }
6886    }
6887
6888    /// On a host WITHOUT the macOS 27 behaviour, the live row must be
6889    /// byte-identical to the catalog row.
6890    ///
6891    /// This is the "does it hurt previous versions?" guard, and it is
6892    /// deliberately written as a property rather than a platform check,
6893    /// because the machine that can run macOS 26 and the machine that runs
6894    /// this suite are not the same machine. Whatever the host reports, the
6895    /// contract is: no parallel support means nothing was added.
6896    ///
6897    /// It therefore proves the macOS 26 no-regression claim by construction
6898    /// instead of by testimony — on such a host
6899    /// `supports_parallel_tool_calls()` is false, this assertion runs, and a
6900    /// capability that leaked in anyway fails the build. On macOS 27 the
6901    /// branch is inert and
6902    /// `apple_foundation_multi_tool_call_tracks_what_the_host_can_actually_do`
6903    /// covers the positive case.
6904    #[test]
6905    fn a_host_without_parallel_support_gets_exactly_the_catalog_capabilities() {
6906        #[cfg(any(
6907            all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6908            all(target_os = "ios", target_arch = "aarch64")
6909        ))]
6910        {
6911            if crate::backend::foundation_models::supports_parallel_tool_calls() {
6912                return;
6913            }
6914            let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6915            let declared = catalog
6916                .iter()
6917                .find(|m| m.id == "apple/foundation:default")
6918                .expect("catalog publishes the Apple FoundationModels row");
6919
6920            let models_dir = tempfile::tempdir().unwrap();
6921            let registry = UnifiedRegistry::new(models_dir.path().to_path_buf());
6922            let live = registry
6923                .models
6924                .get("apple/foundation:default")
6925                .expect("the builtin catalog row must survive into the live registry");
6926
6927            assert_eq!(
6928                live.capabilities, declared.capabilities,
6929                "a host without the macOS 27 behaviour must advertise exactly what \
6930                 the catalog declares — nothing added, nothing removed"
6931            );
6932        }
6933    }
6934
6935    /// `context_size()` reports the OS's own answer, and the catalog literal
6936    /// is the fallback when it can't. Both halves matter: the literal above
6937    /// is what macOS 26.0-26.3 and every non-Apple host keep using, because
6938    /// `SystemLanguageModel.contextSize` only exists from 26.4 on.
6939    ///
6940    /// Deliberately not asserting a specific number on a supported host --
6941    /// the whole point of reading it is that Apple owns the value. Measured
6942    /// 4096 on both macOS 26 and macOS 27; a future OS moving it should
6943    /// change behaviour here without failing this test.
6944    #[test]
6945    fn apple_foundation_context_size_is_either_the_os_answer_or_none() {
6946        #[cfg(any(
6947            all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6948            all(target_os = "ios", target_arch = "aarch64")
6949        ))]
6950        {
6951            if let Some(window) = crate::backend::foundation_models::context_size() {
6952                assert!(
6953                    window >= 512,
6954                    "a context window the framework reports must be usable, got {window}"
6955                );
6956            }
6957        }
6958
6959        // Everywhere else the shim isn't built, so the sentinel must be None
6960        // and the declared `context_length` stands untouched.
6961        #[cfg(not(any(
6962            all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
6963            all(target_os = "ios", target_arch = "aarch64")
6964        )))]
6965        {
6966            let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6967            let row = catalog
6968                .iter()
6969                .find(|model| model.id == "apple/foundation:default")
6970                .expect("catalog publishes the Apple FoundationModels row");
6971            assert_eq!(row.context_length, 4096);
6972        }
6973    }
6974
6975    /// In-process Qwen3 models advertise tool capability because the local
6976    /// generate path now renders Qwen3's `<tools>` chat format and parses
6977    /// `<tool_call>` blocks back out (`tasks::generate::render_chat_prompt` /
6978    /// `parse_tool_calls`). This pins the catalog so the capability claim and
6979    /// the implementation stay in lockstep — if the in-process tool path is
6980    /// ever removed, this test should fail until the claim is dropped too.
6981    #[test]
6982    fn in_process_qwen3_models_declare_tool_use() {
6983        use crate::schema::ModelSource;
6984        let catalog: Vec<ModelSchema> = serde_json::from_str(BUILTIN_CATALOG_JSON).unwrap();
6985        // The mid/large Qwen3 sizes are reliable tool-callers; the 0.6b/1.7b
6986        // tiers legitimately don't advertise tool_use.
6987        let tool_sizes = ["qwen3-4b", "qwen3-8b", "qwen3-30b-a3b"];
6988        let mut checked = 0;
6989        for entry in &catalog {
6990            let in_process = matches!(
6991                entry.source,
6992                ModelSource::Mlx { .. } | ModelSource::Local { .. }
6993            );
6994            if !in_process || !tool_sizes.iter().any(|s| entry.id.contains(s)) {
6995                continue;
6996            }
6997            assert!(
6998                entry.capabilities.contains(&ModelCapability::ToolUse),
6999                "in-process Qwen3 model {} should advertise ToolUse — the local \
7000                 generate path renders/parses tool calls",
7001                entry.id
7002            );
7003            checked += 1;
7004        }
7005        assert_eq!(
7006            checked, 6,
7007            "expected 6 in-process tool-capable Qwen3 entries (3 mlx + 3 gguf)"
7008        );
7009    }
7010
7011    #[test]
7012    fn public_benchmarks_round_trip_through_model_info() {
7013        use crate::schema::BenchmarkScore;
7014        let mut reg = test_registry();
7015        let mut schema = reg
7016            .find_by_name("Qwen3-4B")
7017            .expect("catalog has Qwen3-4B")
7018            .clone();
7019        schema.id = "test/qwen3-4b-with-bench".into();
7020        schema.public_benchmarks = vec![
7021            BenchmarkScore {
7022                name: "MMLU-Pro".into(),
7023                score: 0.482,
7024                harness: Some("5-shot CoT".into()),
7025                source_url: Some("https://example.invalid/qwen3-4b-card".into()),
7026                measured_at: Some("2025-08-12".into()),
7027                runs: None,
7028                spread: None,
7029            },
7030            BenchmarkScore {
7031                name: "HumanEval".into(),
7032                score: 0.713,
7033                harness: Some("pass@1".into()),
7034                source_url: None,
7035                measured_at: None,
7036                runs: None,
7037                spread: None,
7038            },
7039        ];
7040        reg.register(schema);
7041
7042        let stored = reg
7043            .get("test/qwen3-4b-with-bench")
7044            .expect("registered model is retrievable");
7045        let info = ModelInfo::from(stored);
7046        assert_eq!(info.public_benchmarks.len(), 2);
7047
7048        // The serialized JSON shape is what the WS / FFI clients consume.
7049        let json = serde_json::to_string(&info).unwrap();
7050        assert!(json.contains("\"public_benchmarks\""));
7051        assert!(json.contains("\"MMLU-Pro\""));
7052        assert!(json.contains("\"5-shot CoT\""));
7053
7054        // Round-trip back through serde to confirm deserialization works.
7055        let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
7056        assert_eq!(decoded.public_benchmarks.len(), 2);
7057        assert_eq!(decoded.public_benchmarks[0].name, "MMLU-Pro");
7058        assert_eq!(decoded.public_benchmarks[1].name, "HumanEval");
7059    }
7060
7061    #[test]
7062    fn public_benchmarks_default_to_empty_when_absent_in_json() {
7063        // Older user-config JSON written before this field existed must
7064        // still deserialize cleanly into the new ModelSchema shape.
7065        let legacy_json = r#"{
7066            "id": "legacy/test:1",
7067            "name": "Legacy Test",
7068            "provider": "test",
7069            "family": "test",
7070            "version": "",
7071            "capabilities": ["generate"],
7072            "context_length": 4096,
7073            "param_count": "1B",
7074            "quantization": null,
7075            "performance": {},
7076            "cost": {},
7077            "source": { "type": "ollama", "model_tag": "legacy:1" },
7078            "tags": [],
7079            "supported_params": []
7080        }"#;
7081        let schema: ModelSchema = serde_json::from_str(legacy_json).unwrap();
7082        assert!(schema.public_benchmarks.is_empty());
7083    }
7084
7085    #[test]
7086    fn find_by_name() {
7087        let reg = test_registry();
7088        let m = reg.find_by_name("Qwen3-4B").unwrap();
7089        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7090        assert_eq!(m.id, "mlx/qwen3-4b:4bit");
7091        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7092        assert_eq!(m.id, "qwen/qwen3-4b:q4_k_m");
7093        assert!(m.has_capability(ModelCapability::Code));
7094    }
7095
7096    #[test]
7097    fn query_by_capability() {
7098        let reg = test_registry();
7099        let embed_models = reg.query_by_capability(ModelCapability::Embed);
7100        assert_eq!(embed_models.len(), 2);
7101        assert!(embed_models
7102            .iter()
7103            .any(|model| model.name == "Qwen3-Embedding-0.6B"));
7104        assert!(embed_models
7105            .iter()
7106            .any(|model| model.name == "Qwen3-Embedding-0.6B-MLX"));
7107    }
7108
7109    #[test]
7110    fn query_with_filter() {
7111        let reg = test_registry();
7112        let code_small = reg.query(&ModelFilter {
7113            capabilities: vec![ModelCapability::Code],
7114            max_size_mb: Some(3000),
7115            local_only: true,
7116            ..Default::default()
7117        });
7118        // Qwen3-1.7B, Qwen3-1.7B-MLX, Qwen3-4B, and Qwen3-4B-MLX fit and have Code capability.
7119        assert_eq!(code_small.len(), 4);
7120    }
7121
7122    #[test]
7123    fn register_remote() {
7124        let mut reg = test_registry();
7125        let initial_len = reg.list().len();
7126        let initial_reasoning_len = reg
7127            .query(&ModelFilter {
7128                capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
7129                ..Default::default()
7130            })
7131            .len();
7132        let remote = ModelSchema {
7133            id: "anthropic/claude-sonnet-4-6:latest".into(),
7134            name: "Claude Sonnet 4.6".into(),
7135            provider: "anthropic".into(),
7136            family: "claude-4".into(),
7137            version: "latest".into(),
7138            capabilities: vec![
7139                ModelCapability::Generate,
7140                ModelCapability::Code,
7141                ModelCapability::Reasoning,
7142                ModelCapability::ToolUse,
7143            ],
7144            context_length: 200000,
7145            max_output_tokens: None,
7146            param_count: String::new(),
7147            quantization: None,
7148            performance: PerformanceEnvelope {
7149                latency_p50_ms: Some(2000),
7150                ..Default::default()
7151            },
7152            cost: CostModel {
7153                input_per_mtok: Some(3.0),
7154                output_per_mtok: Some(15.0),
7155                ..Default::default()
7156            },
7157            source: ModelSource::RemoteApi {
7158                endpoint: "https://api.anthropic.com/v1/messages".into(),
7159                api_key_env: "ANTHROPIC_API_KEY".into(),
7160                api_key_envs: vec![],
7161                api_version: Some("2023-06-01".into()),
7162                protocol: ApiProtocol::Anthropic,
7163            },
7164            tags: vec![],
7165            supported_params: vec![],
7166            public_benchmarks: vec![],
7167            trust_tier: crate::schema::TrustTier::Curated,
7168            deprecated: false,
7169            available: false,
7170            weights_ready: false,
7171        };
7172
7173        reg.register(remote);
7174        // Same ID as builtin claude-sonnet-4-6 — replaces, count stays same
7175        assert_eq!(reg.list().len(), initial_len);
7176
7177        let reasoning = reg.query(&ModelFilter {
7178            capabilities: vec![ModelCapability::Reasoning, ModelCapability::ToolUse],
7179            ..Default::default()
7180        });
7181        // Replacing an existing remote slot should not change the reasoning/tool-use lineup size.
7182        assert_eq!(reasoning.len(), initial_reasoning_len);
7183    }
7184
7185    #[test]
7186    fn unregister() {
7187        let mut reg = test_registry();
7188        let initial_len = reg.list().len();
7189        let removed = reg.unregister("qwen/qwen3-0.6b:q8_0");
7190        assert!(removed.is_some());
7191        assert_eq!(reg.list().len(), initial_len - 1);
7192    }
7193
7194    #[test]
7195    fn speech_models_are_curated() {
7196        let reg = test_registry();
7197        let stt = reg.query_by_capability(ModelCapability::SpeechToText);
7198        let tts = reg.query_by_capability(ModelCapability::TextToSpeech);
7199        // Parakeet-TDT-MLX + Whisper-large-v3-turbo (cross-platform) + scribe_v1.
7200        assert_eq!(stt.len(), 3);
7201        // Kokoro-6bit/bf16 + Windows-Speech (OS, Windows-only) + Qwen3-TTS + eleven.
7202        assert_eq!(tts.len(), 5);
7203        // whisper.cpp STT is a first-class catalog citizen — the cross-platform
7204        // local STT that runs where MLX (Apple-only) can't.
7205        let whisper = stt
7206            .iter()
7207            .find(|m| m.name == "Whisper-large-v3-turbo-q5_0")
7208            .expect("whisper STT model should be curated");
7209        assert!(whisper.is_local());
7210        assert!(matches!(
7211            whisper.source,
7212            crate::schema::ModelSource::WhisperCpp { .. }
7213        ));
7214    }
7215
7216    #[test]
7217    fn qwen_8b_variants_keep_tool_use_consistent() {
7218        // The GGUF and MLX twins of Qwen3-8B must agree on tool capability, and
7219        // both advertise it: the in-process generate path renders Qwen3's
7220        // <tools> chat format and parses <tool_call> blocks back out (see
7221        // tasks::generate::render_chat_prompt / parse_tool_calls).
7222        let reg = test_registry();
7223        for name in ["Qwen3-8B", "Qwen3-8B-MLX"] {
7224            let model = reg.find_by_name(name).expect("model should exist");
7225            assert!(model.has_capability(ModelCapability::ToolUse));
7226            assert!(model.has_capability(ModelCapability::MultiToolCall));
7227        }
7228    }
7229
7230    #[test]
7231    fn mac_name_resolution_prefers_mlx_siblings() {
7232        // Only used inside the aarch64-macos cfg below; non-mac targets
7233        // keep the test as a smoke compile.
7234        #[allow(unused_variables)]
7235        let reg = test_registry();
7236        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7237        {
7238            assert_eq!(
7239                reg.find_by_name("Qwen3-0.6B").unwrap().id,
7240                "mlx/qwen3-0.6b:6bit"
7241            );
7242            assert_eq!(
7243                reg.find_by_name("Qwen3-1.7B").unwrap().id,
7244                "mlx/qwen3-1.7b:3bit"
7245            );
7246            assert_eq!(
7247                reg.find_by_name("Qwen3-Embedding-0.6B").unwrap().id,
7248                "mlx/qwen3-embedding-0.6b:mxfp8"
7249            );
7250        }
7251    }
7252
7253    #[test]
7254    fn remote_multimodal_models_are_curated_as_vision_capable() {
7255        let reg = test_registry();
7256        for name in [
7257            "claude-opus-4-7",
7258            "claude-opus-4-6",
7259            "claude-sonnet-4-6",
7260            "claude-haiku-4-5",
7261            "gpt-5.4",
7262            "gpt-5.4-mini",
7263            "o3",
7264            "o4-mini",
7265            "gpt-4.1-mini",
7266            "gemini-2.5-pro",
7267            "gemini-2.5-flash",
7268        ] {
7269            let model = reg.find_by_name(name).expect("model should exist");
7270            assert!(
7271                model.has_capability(ModelCapability::Vision),
7272                "{name} should be curated as vision-capable"
7273            );
7274        }
7275    }
7276
7277    #[test]
7278    fn qwen25vl_entries_are_replaced_by_qwen3vl_in_builtin_catalog() {
7279        let reg = test_registry();
7280
7281        let stale_ids = [
7282            // Native MLX text tower can't tokenize images — never advertise.
7283            "mlx/qwen2.5-vl-3b:4bit",
7284            "mlx/qwen2.5-vl-7b:4bit",
7285            // Qwen2.5-VL is superseded by Qwen3-VL; drop the mlx-vlm CLI
7286            // catalog entries so callers route to the upgraded family.
7287            "mlx-vlm/qwen2.5-vl-3b:4bit",
7288            "mlx-vlm/qwen2.5-vl-7b:4bit",
7289            // Same supersession applies to the vLLM-MLX route.
7290            "vllm-mlx/qwen2.5-vl-3b:4bit",
7291        ];
7292        for id in stale_ids {
7293            assert!(
7294                reg.get(id).is_none(),
7295                "{id} is superseded by Qwen3-VL; the catalog must not advertise it"
7296            );
7297        }
7298
7299        let vision_ids: Vec<&str> = reg
7300            .query_by_capability(ModelCapability::Vision)
7301            .into_iter()
7302            .map(|model| model.id.as_str())
7303            .collect();
7304        for stale in stale_ids {
7305            assert!(
7306                !vision_ids.contains(&stale),
7307                "{stale} must not be reachable through the Vision capability index"
7308            );
7309        }
7310        assert!(
7311            vision_ids.contains(&"mlx-vlm/qwen3-vl-2b:bf16"),
7312            "Qwen3-VL is the supported local VL family and must route as Vision"
7313        );
7314    }
7315
7316    #[test]
7317    fn gemini_models_are_curated_for_multimodal_tool_use() {
7318        let reg = test_registry();
7319        for name in ["gemini-2.5-pro", "gemini-2.5-flash"] {
7320            let model = reg.find_by_name(name).expect("model should exist");
7321            assert!(model.has_capability(ModelCapability::Vision));
7322            assert!(model.has_capability(ModelCapability::ToolUse));
7323            assert!(model.has_capability(ModelCapability::MultiToolCall));
7324        }
7325    }
7326
7327    #[test]
7328    fn model_info_publishes_declared_prices_and_keeps_unpriced_distinct_from_free() {
7329        let reg = test_registry();
7330
7331        // A priced remote row publishes every declared rate plus its tiers.
7332        let opus = reg
7333            .list()
7334            .into_iter()
7335            .find(|m| m.id == "openrouter/anthropic/claude-opus-4.8")
7336            .map(ModelInfo::from)
7337            .expect("curated opus-4.8 row is present on first boot");
7338        assert_eq!(opus.cost.input_per_mtok, Some(5.0));
7339        assert_eq!(opus.cost.output_per_mtok, Some(25.0));
7340        assert_eq!(opus.cost.cache_read_input_per_mtok, Some(0.5));
7341        assert_eq!(opus.cost.cache_write_input_per_mtok, Some(6.25));
7342
7343        // Tiered pricing survives the projection where a model declares it.
7344        let gpt = reg
7345            .list()
7346            .into_iter()
7347            .find(|m| m.id == "openrouter/openai/gpt-5.4")
7348            .map(ModelInfo::from)
7349            .expect("curated gpt-5.4 row");
7350        assert_eq!(gpt.cost.pricing_tiers.len(), 1);
7351        assert_eq!(gpt.cost.prices_for(272_000).input_per_mtok, Some(5.0));
7352
7353        // A local model that declares no prices reports them ABSENT. `null`
7354        // and `0.0` are different facts and must stay different on the wire.
7355        let local = reg
7356            .list()
7357            .into_iter()
7358            .find(|m| m.is_local() && m.cost.input_per_mtok.is_none())
7359            .map(ModelInfo::from)
7360            .expect("the built-in catalog ships unpriced local models");
7361        let json = serde_json::to_value(&local).unwrap();
7362        assert!(json["cost"]["input_per_mtok"].is_null());
7363        assert!(json["cost"]["output_per_mtok"].is_null());
7364        assert_ne!(json["cost"]["input_per_mtok"], serde_json::json!(0.0));
7365    }
7366
7367    #[test]
7368    fn a_hand_registered_copy_of_a_curated_id_does_not_double_the_row() {
7369        let mut reg = test_registry();
7370        let id = "openrouter/anthropic/claude-opus-4.8";
7371        assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
7372
7373        // The runtime registry is keyed by id, so a user registration of the
7374        // same id replaces the curated row rather than appending a duplicate.
7375        let mut copy = reg
7376            .list()
7377            .into_iter()
7378            .find(|m| m.id == id)
7379            .cloned()
7380            .expect("curated row");
7381        copy.name = "hand-registered".into();
7382        reg.register_user_model(copy);
7383        assert_eq!(reg.list().iter().filter(|m| m.id == id).count(), 1);
7384    }
7385
7386    /// Scoped to `ModelInfo` — the `models.list_unified` projection — on
7387    /// purpose. `models.search` wraps this same struct but adds `family`,
7388    /// which for a managed alias names the upstream model family and is
7389    /// matched against by queries. That is pre-existing (`frontier-deep`
7390    /// already carries `claude-4.6`) and out of scope here; this test locks
7391    /// the surface the guarantee actually holds on rather than implying a
7392    /// registry-wide one it does not.
7393    #[test]
7394    fn managed_alias_publishes_prices_without_disclosing_the_upstream_id_in_the_catalog_view() {
7395        let reg = test_registry();
7396        let alias = reg
7397            .list()
7398            .into_iter()
7399            .find(|m| m.id == "parslee/openrouter/frontier-deep-next")
7400            .map(ModelInfo::from)
7401            .expect("managed alias for the new curated row");
7402
7403        assert_eq!(alias.cost.input_per_mtok, Some(5.0));
7404        assert_eq!(alias.cost.output_per_mtok, Some(25.0));
7405        assert_eq!(alias.cost.cache_read_input_per_mtok, Some(0.5));
7406        assert_eq!(alias.cost.cache_write_input_per_mtok, Some(6.25));
7407
7408        let wire = serde_json::to_string(&alias).unwrap();
7409        assert!(!wire.contains("claude-opus-4.8"));
7410        assert!(!wire.contains("anthropic/"));
7411    }
7412
7413    #[test]
7414    fn model_info_from_an_older_daemon_without_cost_still_parses() {
7415        // A newly built client must not fail to read a catalog produced before
7416        // `cost` existed; the row deserializes with an all-absent cost.
7417        let legacy = serde_json::json!({
7418            "id": "legacy/model",
7419            "name": "legacy",
7420            "provider": "legacy",
7421            "capabilities": ["generate"],
7422            "param_count": "",
7423            "size_mb": 0,
7424            "context_length": 8192,
7425            "available": true,
7426            "is_local": false
7427        });
7428        let info: ModelInfo = serde_json::from_value(legacy).expect("older catalog row parses");
7429        assert!(info.cost.input_per_mtok.is_none());
7430        assert!(info.cost.output_per_mtok.is_none());
7431        assert!(info.cost.pricing_tiers.is_empty());
7432        assert!(info.max_output_tokens.is_none());
7433        assert!(info.car_enabled, "legacy rows default to enabled");
7434        assert!(!info.can_remove);
7435        assert!(!info.in_use);
7436        assert!(info.management_evidence.is_none());
7437    }
7438
7439    #[test]
7440    fn visual_generation_models_are_curated() {
7441        let reg = test_registry();
7442        assert_eq!(
7443            reg.query_by_capability(ModelCapability::ImageGeneration)
7444                .len(),
7445            1
7446        );
7447        assert_eq!(
7448            reg.query_by_capability(ModelCapability::VideoGeneration)
7449                .len(),
7450            1
7451        );
7452    }
7453}
7454
7455/// Structural validation of the built-in catalog.
7456///
7457/// The catalog is hand-edited JSON compiled into the binary, so a typo in it is
7458/// a shipped bug that no compiler catches. It went seven weeks without a new
7459/// text model while the open-weight world moved through three generations, and
7460/// the entry added to end that drought carried `param_count: "MoE"` — a string
7461/// the scorer cannot parse — which nothing would have caught. These are the
7462/// invariants the rest of the code already assumes.
7463#[cfg(test)]
7464mod builtin_catalog_validation {
7465    use super::*;
7466    use crate::schema::ModelSource;
7467
7468    /// Sources whose weights CAR (or a runtime CAR supervises) actually loads.
7469    fn weight_repo(source: &ModelSource) -> Option<&str> {
7470        match source {
7471            ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
7472            ModelSource::Local { hf_repo, .. } => Some(hf_repo),
7473            ModelSource::ManagedVllmMlx { hf_repo, .. } => Some(hf_repo),
7474            _ => None,
7475        }
7476    }
7477
7478    #[test]
7479    fn ids_are_unique() {
7480        let catalog = builtin_catalog();
7481        let mut seen: Vec<&str> = Vec::new();
7482        for model in &catalog {
7483            assert!(
7484                !seen.contains(&model.id.as_str()),
7485                "duplicate catalog id `{}` — the later entry silently shadows the earlier",
7486                model.id
7487            );
7488            seen.push(&model.id);
7489        }
7490    }
7491
7492    #[test]
7493    fn source_kind_is_the_serialized_source_tag() {
7494        for model in builtin_catalog() {
7495            let tagged = serde_json::to_value(&model.source).unwrap();
7496            assert_eq!(
7497                tagged["type"].as_str(),
7498                Some(model.source.kind()),
7499                "{}",
7500                model.id
7501            );
7502            assert_eq!(
7503                crate::ModelInfo::from(&model).source_kind,
7504                model.source.kind()
7505            );
7506        }
7507        let codex = builtin_catalog()
7508            .into_iter()
7509            .find(|model| model.id == "openai/gpt-5.6-sol:high")
7510            .unwrap();
7511        let api = builtin_catalog()
7512            .into_iter()
7513            .find(|model| model.id == "openai/gpt-5.6-sol:latest")
7514            .unwrap();
7515        assert_eq!(crate::ModelInfo::from(&codex).source_kind, "codex_cli");
7516        assert_eq!(crate::ModelInfo::from(&api).source_kind, "remote_api");
7517    }
7518
7519    #[test]
7520    fn exact_frontier_rows_lock_native_selectors_and_digests() {
7521        let catalog = builtin_catalog();
7522        for (id, name, version, expected_digest) in [
7523            (
7524                "openai/gpt-5.5-2026-04-23",
7525                "gpt-5.5-2026-04-23",
7526                "2026-04-23",
7527                "7eeaecc6e83ce409b37cc68e9c44ba84d64c5f9690179a494bdcb25a93f0aac3",
7528            ),
7529            (
7530                "anthropic/claude-opus-4-8",
7531                "claude-opus-4-8",
7532                "4.8",
7533                "10504959e51dc76c3563df91ae2eaba57cf814834ecd657232c50f264f9e735e",
7534            ),
7535            (
7536                "openai/gpt-5.6-sol:high",
7537                "gpt-5.6-sol:high",
7538                "latest",
7539                "6b552a2bd00193915c624ce98d2ae7c9ccd73e037e735760ab903105413d587b",
7540            ),
7541        ] {
7542            let row = catalog
7543                .iter()
7544                .find(|model| model.id == id)
7545                .unwrap_or_else(|| panic!("missing exact production row {id}"));
7546            assert_eq!(row.name, name);
7547            assert_eq!(row.version, version);
7548            assert_eq!(
7549                crate::catalog_identity::row_digest(row).unwrap(),
7550                expected_digest,
7551                "CAR row digest drifted for {id}"
7552            );
7553        }
7554    }
7555
7556    #[test]
7557    fn weight_repos_are_well_formed_huggingface_ids() {
7558        for model in builtin_catalog() {
7559            let Some(repo) = weight_repo(&model.source) else {
7560                continue;
7561            };
7562            assert_eq!(
7563                repo.split('/').count(),
7564                2,
7565                "{}: `{repo}` is not an `org/name` HuggingFace id",
7566                model.id
7567            );
7568            assert!(
7569                !repo.split('/').any(str::is_empty),
7570                "{}: `{repo}` has an empty path segment",
7571                model.id
7572            );
7573            assert!(
7574                !repo.contains(char::is_whitespace),
7575                "{}: `{repo}` contains whitespace",
7576                model.id
7577            );
7578        }
7579    }
7580
7581    /// `param_billions_total` parses a leading number; anything else silently
7582    /// falls back to a size estimate, so an unparseable value is a scoring bug
7583    /// that presents as a mysteriously bad recommendation.
7584    #[test]
7585    fn param_counts_are_parseable_or_deliberately_empty() {
7586        for model in builtin_catalog() {
7587            if weight_repo(&model.source).is_none() || model.param_count.is_empty() {
7588                continue;
7589            }
7590            assert!(
7591                model.param_count.starts_with(|c: char| c.is_ascii_digit()),
7592                "{}: param_count `{}` does not start with a number, so the quality \
7593                 prior cannot read it — leave it empty rather than descriptive",
7594                model.id,
7595                model.param_count
7596            );
7597        }
7598    }
7599
7600    /// CAR supervision is an explicit source contract. Endpoint topology must
7601    /// never be used to infer ownership.
7602    #[test]
7603    fn catalog_vllm_mlx_entries_use_explicit_managed_ownership() {
7604        for model in builtin_catalog() {
7605            if !model.is_vllm_mlx() {
7606                continue;
7607            }
7608            assert!(
7609                model.is_car_managed_vllm_mlx(),
7610                "{}: a CAR-supervised catalog row must opt into ManagedVllmMlx; \
7611                 loopback alone cannot confer ownership",
7612                model.id
7613            );
7614        }
7615    }
7616
7617    #[test]
7618    fn generate_capable_models_declare_a_context_window() {
7619        for model in builtin_catalog() {
7620            if !model.has_capability(crate::schema::ModelCapability::Generate) {
7621                continue;
7622            }
7623            assert!(
7624                model.context_length > 0,
7625                "{}: a generate-capable model with no context_length breaks budget sizing",
7626                model.id
7627            );
7628        }
7629    }
7630
7631    #[test]
7632    fn every_entry_declares_at_least_one_capability() {
7633        for model in builtin_catalog() {
7634            assert!(
7635                !model.capabilities.is_empty(),
7636                "{}: an entry with no capabilities can never be routed to",
7637                model.id
7638            );
7639        }
7640    }
7641}
7642
7643#[cfg(test)]
7644mod gguf_quantization_tests {
7645    use crate::schema::{QuantScheme, Quantization};
7646
7647    fn quantization_from_gguf_filename(name: &str) -> Option<Quantization> {
7648        Quantization::from_gguf_filename(name)
7649    }
7650
7651    #[test]
7652    fn reads_the_quantization_a_gguf_file_names() {
7653        let cases = [
7654            ("Qwen3-8B-Q4_K_M.gguf", "Q4_K_M", QuantScheme::KQuantMixed),
7655            (
7656                "Qwen3-Embedding-0.6B-Q8_0.gguf",
7657                "Q8_0",
7658                QuantScheme::RtnBlock,
7659            ),
7660            (
7661                "ggml-large-v3-turbo-q5_0.gguf",
7662                "q5_0",
7663                QuantScheme::RtnBlock,
7664            ),
7665            ("model-IQ4_XS.gguf", "IQ4_XS", QuantScheme::KQuantMixed),
7666        ];
7667        for (filename, label, scheme) in cases {
7668            let q = quantization_from_gguf_filename(filename)
7669                .unwrap_or_else(|| panic!("no quantization found in {filename}"));
7670            assert_eq!(q.label, label, "label for {filename}");
7671            assert_eq!(q.scheme, scheme, "scheme for {filename}");
7672        }
7673    }
7674
7675    /// A name with nothing quant-shaped in it must yield nothing, not a guess.
7676    #[test]
7677    fn returns_none_when_the_name_says_nothing() {
7678        for filename in ["model.gguf", "llama-2-7b-chat.gguf", "ggml-base.gguf"] {
7679            assert!(
7680                quantization_from_gguf_filename(filename).is_none(),
7681                "should not have guessed from {filename}"
7682            );
7683        }
7684    }
7685
7686    /// Scanning from the right means a quant-shaped word earlier in the model
7687    /// name cannot outrank the real suffix.
7688    #[test]
7689    fn the_rightmost_match_wins() {
7690        let q = quantization_from_gguf_filename("q8-experiment-Q4_K_M.gguf").unwrap();
7691        assert_eq!(q.label, "Q4_K_M");
7692    }
7693}
7694
7695#[cfg(test)]
7696mod mlx_executor_discrimination_tests {
7697    use super::*;
7698
7699    /// `ModelSource::Mlx` is a weight-format tag shared by five executors, and
7700    /// only one of them is the in-process Swift loader. Gating the whole source
7701    /// on that loader marked `mflux`, `ltx-2-mlx` and the `mlx_vlm` CLI rows
7702    /// unavailable on any Mac whose Swift build merely failed — subprocesses
7703    /// that never touch the linked library, and the only local image/video
7704    /// backends there are.
7705    ///
7706    /// Asserted against the real builtin catalog rather than a hand-built
7707    /// fixture: a fixture written beside this helper would agree with it by
7708    /// construction, and the thing under test is precisely whether the
7709    /// predicate matches the catalog's actual shape.
7710    #[test]
7711    fn only_in_process_text_rows_need_the_swift_loader() {
7712        let catalog = builtin_catalog();
7713        let row = |id: &str| {
7714            catalog
7715                .iter()
7716                .find(|m| m.id == id)
7717                .unwrap_or_else(|| panic!("{id} missing from the builtin catalog"))
7718        };
7719
7720        for id in [
7721            "mlx/flux-1-lite-8b:q4",    // mflux subprocess
7722            "mlx/ltx-2.3:q4",           // ltx-2-mlx subprocess
7723            "mlx-vlm/qwen3-vl-2b:bf16", // mlx_vlm Python CLI
7724            "mlx/kokoro-82m:bf16",      // managed mlx-audio venv
7725            "mlx/parakeet-tdt-0.6b-v3:default",
7726        ] {
7727            let m = row(id);
7728            assert!(
7729                matches!(m.source, ModelSource::Mlx { .. }),
7730                "{id}: precondition — this test is only meaningful for Mlx-sourced rows"
7731            );
7732            assert!(
7733                !mlx_row_needs_in_process_loader(m),
7734                "{id} runs in a subprocess; its availability must not depend on the \
7735                 in-process Swift loader"
7736            );
7737        }
7738
7739        // Positive control: the predicate must still say yes to something, or
7740        // the assertions above would pass against a function returning false.
7741        for id in ["mlx/qwen3-4b:4bit", "mlx/gemma-4-12b-it:4bit"] {
7742            assert!(
7743                mlx_row_needs_in_process_loader(row(id)),
7744                "{id} is served in-process and does need the loader"
7745            );
7746        }
7747    }
7748}
7749
7750#[cfg(test)]
7751mod local_availability_tests {
7752    use super::*;
7753    use crate::schema::ModelSchema;
7754    use tempfile::TempDir;
7755
7756    fn gguf_row(id: &str, hf_repo: &str) -> ModelSchema {
7757        let mut schema: ModelSchema = serde_json::from_value(serde_json::json!({
7758            "id": id,
7759            "name": id.replace('/', "-"),
7760            "provider": "qwen",
7761            "family": "qwen3",
7762            "capabilities": ["generate"],
7763            "context_length": 32768,
7764            "param_count": "8B",
7765            "source": {
7766                "type": "local",
7767                "hf_repo": hf_repo,
7768                "hf_filename": "model.gguf",
7769                "tokenizer_repo": hf_repo,
7770            },
7771            "cost": { "size_mb": 4900 },
7772        }))
7773        .unwrap();
7774        schema.available = false;
7775        schema
7776    }
7777
7778    /// GGUF is the local path on every machine that is not Apple-Silicon-MLX —
7779    /// i.e. the CUDA machines. `ensure_local` downloads `model.gguf` and
7780    /// `tokenizer.json` from the declared repo on first use, exactly as the MLX
7781    /// path does, so a declared repo is functionally available.
7782    ///
7783    /// This branch was left out of #164, which gave the MLX path that rule, and
7784    /// out of whisper.cpp's unconditional `true`. The result was a fresh CUDA
7785    /// install reporting every local model unavailable, the router dropping
7786    /// them from fallback chains, and the user routed to cloud with an idle GPU
7787    /// one download away.
7788    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7789    #[test]
7790    fn a_declared_repo_is_available_before_it_is_downloaded() {
7791        let _environment = crate::openrouter::test_environment_scope();
7792        let tmp = TempDir::new().unwrap();
7793        let models = tmp.path().join("models");
7794        std::fs::create_dir_all(&models).unwrap();
7795
7796        let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
7797        reg.register(gguf_row("qwen/test-8b:q4_k_m", "Qwen/Qwen3-8B-GGUF"));
7798
7799        let model = reg.get("qwen/test-8b:q4_k_m").expect("registered");
7800        assert!(
7801            model.available,
7802            "a GGUF model with a repo to fetch from must not report unavailable \
7803             just because nothing has downloaded it yet"
7804        );
7805    }
7806
7807    /// The exception, and the reason the rule is `!hf_repo.is_empty()` rather
7808    /// than blanket `true`: rows from the local-directory scan have nowhere to
7809    /// fetch from, so they stay gated on the file being physically present.
7810    #[test]
7811    fn a_row_with_nowhere_to_fetch_from_stays_unavailable() {
7812        let _environment = crate::openrouter::test_environment_scope();
7813        let tmp = TempDir::new().unwrap();
7814        let models = tmp.path().join("models");
7815        std::fs::create_dir_all(&models).unwrap();
7816
7817        let mut reg = UnifiedRegistry::new_with_state_root(tmp.path().to_path_buf(), models);
7818        reg.register(gguf_row("local/scanned", ""));
7819
7820        let model = reg.get("local/scanned").expect("registered");
7821        assert!(
7822            !model.available,
7823            "an empty hf_repo has no download to promise"
7824        );
7825    }
7826
7827    #[test]
7828    fn builtin_credential_names_cover_remote_and_proprietary_auth() {
7829        let names = builtin_credential_env_names();
7830        for name in [
7831            "OPENAI_API_KEY",
7832            "ANTHROPIC_API_KEY",
7833            crate::openrouter::API_KEY_ENV,
7834        ] {
7835            assert!(names.contains(name), "built-in catalog omitted {name}");
7836        }
7837    }
7838}