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