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