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