Skip to main content

car_inference/
lib.rs

1//! # car-inference
2//!
3//! Local model inference for the Common Agent Runtime.
4//!
5//! Provides on-device inference using Candle with automatic hardware detection:
6//! - **macOS**: Metal (Apple Silicon GPU)
7//! - **Linux**: CUDA (NVIDIA GPU) or CPU fallback
8//!
9//! Ships with Qwen3 models downloaded on first use from HuggingFace.
10//! Supports remote API models (OpenAI, Anthropic, Google) via the same schema.
11//!
12//! ## Architecture
13//!
14//! Models are first-class typed resources described by `ModelSchema` (analogous
15//! to `ToolSchema`). The `UnifiedRegistry` holds local and remote models.
16//! The `AdaptiveRouter` selects the best model using a three-phase strategy:
17//! filter → score → explore. The `OutcomeTracker` learns from results to
18//! improve routing over time.
19//!
20//! ## Dual purpose
21//!
22//! 1. **Internal** — powers skill learning/repair, semantic memory, policy evaluation
23//! 2. **Service** — exposes `infer`, `embed`, `classify` as built-in CAR tools
24
25pub mod action_ledger;
26pub mod adaptive_router;
27pub mod aws_sigv4;
28pub mod backend;
29pub mod backend_cache;
30pub mod calibration;
31pub mod catalog;
32pub mod catalog_identity;
33pub mod concierge;
34pub mod discovery;
35pub mod doctor;
36pub mod download;
37pub mod handle;
38pub mod hardware;
39pub mod hf_schema;
40pub mod intent;
41pub mod key_pool;
42pub mod lane_defaults;
43pub mod managed_venv;
44pub mod media_tokens;
45pub mod model_management;
46pub mod models;
47pub mod nudge;
48pub mod offload;
49pub mod openrouter;
50pub mod outcome;
51pub mod parslee_credential;
52pub mod protocol;
53pub mod recommend;
54pub mod registry;
55pub mod remote;
56pub mod resource_policy;
57pub mod router;
58pub mod routing_ext;
59pub mod runner;
60pub mod schema;
61pub mod scoreboard;
62pub mod search;
63pub mod service;
64pub mod stream;
65pub mod tasks;
66/// Crate-private: reqwest client construction that degrades instead of
67/// panicking when the OS trust store cannot be loaded. Not part of the public
68/// API — see the module docs for the failure it exists to survive.
69pub(crate) mod tls_client;
70pub mod uninstall;
71pub mod update_prefs;
72pub mod upgrade;
73pub mod usage_profile;
74pub mod vllm_mlx;
75pub mod vllm_pool;
76pub mod vllm_runtime;
77
78use std::path::{Path, PathBuf};
79use std::sync::Arc;
80use std::time::Instant;
81use std::time::{SystemTime, UNIX_EPOCH};
82
83use reqwest::multipart::{Form, Part};
84use serde::Serialize;
85use thiserror::Error;
86use tokio::io::AsyncReadExt;
87use tokio::process::Command;
88use tokio::sync::Mutex;
89use tokio::sync::RwLock;
90use tracing::{debug, instrument};
91
92// --- New types ---
93pub use action_ledger::{ConciergeActionEntry, ConciergeActionKind};
94pub use adaptive_router::{
95    AdaptiveRouter, AdaptiveRoutingDecision, RoutingConfig, RoutingStrategy,
96};
97pub use catalog_identity::{CatalogModelRow, CatalogSnapshot};
98pub use concierge::{
99    decide_concierge, evaluate_concierge, ConciergeDecision, ConciergeMode, ConciergeStatus,
100    ConciergeSuggestion, DismissReason, DismissalRecord, ModelHealth,
101    DEFAULT_CONCIERGE_THROTTLE_SECS, DEFAULT_WATCHED_USE_CASES,
102};
103pub use download::{DownloadEvent, DownloadProgress, ProgressSink};
104pub use handle::InferenceHandle;
105pub use intent::{IntentHint, Privacy, QualityTier, TaskHint, TierWeights, UseCase, UseCaseRole};
106pub use key_pool::{KeyPool, KeyStats};
107pub use lane_defaults::{LaneDefault, LaneDefaults};
108pub use nudge::{NudgeDecision, NudgeState, UpgradeNudge};
109pub use outcome::{
110    prune_ledger, read_ledger, CodeOutcome, InferenceOutcome, InferenceTask, InferredOutcome,
111    ModelProfile, OutcomeLedgerEntry, OutcomeTracker,
112};
113pub use recommend::{
114    model_fit, platform_compatible, recommend, recommend_with_policy, FitStatus, ModelFit,
115    ModelFitStatus, Recommendation, RecommendationSet,
116};
117pub use resource_policy::{
118    estimate_model_memory, estimate_model_memory_with_measured_weights, evaluate_resources,
119    AcceleratorResourceBudget, EffectiveResourceBudget, FileResourcePolicyRepository,
120    LocalAdmissionCoordinator, LocalLoadPreflight, LocalLoadVerdict, ModelMemoryEstimate,
121    ModelResourceEvidence, ResourceEvaluation, ResourcePolicy, ResourcePolicyError,
122    ResourcePolicyLoadEvidence, ResourcePolicyLoadSource, ResourcePolicyRepository,
123    ResourceProfile, RECOMMENDATION_CONTEXT_TOKENS,
124};
125pub use update_prefs::{UpdateChannel, UpdatePolicy, UpdatePreferences};
126pub use upgrade::{HuggingFaceProbe, UpgradeFinding, UpgradeSource, UpstreamProbe};
127pub use usage_profile::{use_case_for_task, LaneUsage, UsageProfile};
128
129/// Current Unix time in seconds (concierge action timestamps).
130fn now_unix() -> u64 {
131    std::time::SystemTime::now()
132        .duration_since(std::time::UNIX_EPOCH)
133        .map(|d| d.as_secs())
134        .unwrap_or(0)
135}
136pub use offload::{
137    clear_remote_deadline, current_controlled_termination_token, current_inference_control_id,
138    current_local_offload, current_remote_deadline, ensure_not_controlled_terminated,
139    is_offload_worker, scope_inference_control_id, set_local_offload, set_remote_deadline,
140    ControlledTerminationToken, InferenceTerminationAck, LocalGenerationOffload,
141    LocalOffloadResult, LocalOffloadStream, LocalWorkerAdmission, LocalWorkerResidency,
142    RemoteDeadline,
143};
144pub use registry::{
145    ModelFilter, ModelInfo, ModelRuntimeRequirement, ModelUpgrade, UnifiedRegistry,
146};
147pub use remote::RemoteBackend;
148pub use routing_ext::{
149    CircuitBreaker, CircuitBreakerRegistry, CircuitState, ImplicitSignal, ImplicitSignalType,
150    RoutingMode, SpendControl, SpendLimitExceeded, SpendLimits, SpendStatus,
151};
152pub use runner::{
153    current_inference_runner, set_inference_runner, EventEmitter, InferenceRunner, RunnerError,
154    RunnerResult,
155};
156pub use schema::{
157    ApiProtocol, ApproxCost, BenchmarkScore, CostModel, ModelCapability, ModelSchema, ModelSource,
158    PerformanceEnvelope, ProprietaryAuth, QuantScheme, Quantization, TrustTier,
159};
160
161// --- Legacy re-exports (kept for backward compatibility) ---
162pub use adaptive_router::TaskComplexity;
163#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
164pub use backend::CandleBackend;
165#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
166pub use backend::EmbeddingBackend;
167#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
168pub use backend::MlxBackend;
169pub use hardware::HardwareInfo;
170pub use models::{ModelRegistry, ModelRole};
171pub use router::{ModelRouter, RoutingDecision};
172pub use stream::{StreamAccumulator, StreamEvent};
173pub use tasks::{
174    parse_boxes, BoundingBox, ClassifyRequest, ClassifyResult, ContentBlock, EmbedRequest,
175    GenerateImageRequest, GenerateImageResult, GenerateParams, GenerateRequest,
176    GenerateVideoRequest, GenerateVideoResult, GroundRequest, GroundResult, Message, Provenance,
177    RerankRequest, RerankResult, RerankedDocument, ResponseFormat, RoutingWorkload,
178    SynthesizeRequest, SynthesizeResult, ThinkingMode, ToolCall, TranscribeRequest,
179    TranscribeResult, VideoMode,
180};
181// TokenUsage is defined in this module and already public
182
183#[derive(Error, Debug)]
184pub enum InferenceError {
185    #[error("model not found: {0}")]
186    ModelNotFound(String),
187
188    /// Adaptive routing could not honor a caller-required model separation
189    /// boundary. Raised before dispatch, so no excluded backend serves even a
190    /// failed attempt.
191    #[error("no eligible model remains after required exclusions: {excluded_models}")]
192    NoEligibleModel { excluded_models: String },
193
194    #[error("model download failed: {0}")]
195    DownloadFailed(String),
196
197    #[error("inference failed: {0}")]
198    InferenceFailed(String),
199
200    /// The caller bound inference to a catalog row/revision that no longer
201    /// matches the daemon's request snapshot. This is an optimistic
202    /// concurrency rejection, not a provider failure; it must fail before any
203    /// dispatch and retain a typed wire mapping at the daemon boundary.
204    #[error("catalog precondition mismatch: {detail}")]
205    CatalogPreconditionMismatch { detail: String },
206
207    /// Exact isolated-worker kill + wait confirmed termination for this
208    /// request. The server-owned registry decides whether the outward terminal
209    /// is cancel or deadline; the inference engine uses this sentinel only to
210    /// stop retries/fallbacks without penalizing model health.
211    #[error("controlled inference termination confirmed")]
212    ControlledTermination,
213
214    #[error(transparent)]
215    ModelManagement(#[from] model_management::ModelManagementError),
216
217    /// A CAR-managed local model could not be admitted without violating the
218    /// user's saved allocation or the machine's live emergency reserve.
219    #[error("{recovery}")]
220    LocalResourceBlocked {
221        preflight: resource_policy::LocalLoadPreflight,
222        recovery: String,
223    },
224
225    /// A remote call that failed on a *retryable* class — 5xx / 429 / 529 /
226    /// timeout / connection reset — after the bounded retry budget was
227    /// exhausted. Distinct from [`InferenceError::InferenceFailed`] so a
228    /// caller can tell "infra blip, safe to re-run" from "the request itself
229    /// is wrong" (4xx / auth / validation). `status` carries the final HTTP
230    /// status when the failure was an HTTP response; `None` for a transport
231    /// or timeout error. Used by `car run-task` to classify a run as
232    /// `infra_inference` (re-run) vs a non-retryable failure (alert).
233    #[error("transient remote failure after retries (status={status:?}): {message}")]
234    Transient {
235        status: Option<u16>,
236        message: String,
237    },
238
239    /// The CALLER'S armed deadline (`infer.deadline`) elapsed while the remote
240    /// request was still in flight or before a retry could fit inside what
241    /// remained. Distinct from [`InferenceError::Transient`] — this is not an
242    /// infra blip and re-running with the same deadline hits the same wall;
243    /// the caller asked for exactly this bound and the error names it so the
244    /// termination is attributable to the deadline that was applied (car-eyj:
245    /// the old shape reported -32603 transient at a ceiling no config exposed).
246    #[error("deadline exceeded: the caller's {applied_ms} ms infer deadline elapsed after {elapsed_ms} ms; last attempt: {last_error}")]
247    DeadlineExceeded {
248        applied_ms: u64,
249        elapsed_ms: u64,
250        last_error: String,
251    },
252
253    /// A request mode is accepted on the public surface but the
254    /// selected backend hasn't wired it yet. Distinct from
255    /// `InferenceFailed` so callers can distinguish "backend can't"
256    /// from "backend tried and something went wrong".
257    #[error("mode {mode} not implemented on backend {backend}: {reason}")]
258    UnsupportedMode {
259        mode: &'static str,
260        backend: &'static str,
261        reason: &'static str,
262    },
263
264    /// The provider **account** rejected the call — key absent or rejected
265    /// (401/403), or out of credits/quota (402).
266    ///
267    /// Account-wide, so it says nothing about the model that happened to be
268    /// selected. Booking it as a model failure benches healthy models over a
269    /// billing problem, and — because the health EMA is a 30-day window and the
270    /// circuit breaker has its own cooldown — the penalty outlives the fix: the
271    /// user tops up their credits and the router still avoids the models
272    /// (Parslee-ai/car#650). Distinct from `InferenceFailed` so the dispatch
273    /// loop can resolve it as an unattributed receipt instead.
274    ///
275    /// `provider` is the schema's provider label, so the dispatch loop can drop
276    /// every remaining candidate from the same account rather than replaying
277    /// the identical rejection down the fallback chain.
278    #[error("{provider} account rejected the request (HTTP {status}): {message}")]
279    ProviderAccount {
280        provider: String,
281        status: u16,
282        message: String,
283    },
284
285    /// No usable credential for a provider — and *why*, as data rather than
286    /// prose.
287    ///
288    /// The message text already distinguished the cases (#803), but only in the
289    /// text: a consumer wanting to branch on "token aged out mid-run" versus
290    /// "never signed in" had to substring-match English that could be reworded
291    /// at any time. #797 asked for the distinction to be matchable
292    /// programmatically, which is what [`CredentialFailure`] is for.
293    ///
294    /// **The Display output opens with the historical prefix verbatim** —
295    /// `no credential for proprietary provider '<provider>'`. That is load
296    /// bearing, not cosmetic: `native_loop::is_auth_failure` (which drives the
297    /// wait-for-sign-in path) and the coder-ab harness's `INFRA_MARKERS` (which
298    /// keeps auth casualties out of a benchmark denominator) both classify on
299    /// it as a substring. Rewording the opening would silently reclassify auth
300    /// failures as ordinary errors in both.
301    #[error("no credential for proprietary provider '{provider}' (model {model}): {detail}")]
302    CredentialUnavailable {
303        provider: String,
304        model: String,
305        /// Machine-readable classification — branch on this, not on `detail`.
306        reason: CredentialFailure,
307        /// Human-facing explanation and remedy. Wording is not a contract.
308        detail: String,
309    },
310
311    /// The request was refused on **content** grounds by something in front of
312    /// the model — a gateway safety filter, not the model's own judgement.
313    ///
314    /// Distinct from `InferenceFailed` because the three things a caller wants
315    /// to do about it are all different from what they would do about a crash,
316    /// and all three were impossible while it looked like one
317    /// (Parslee-ai/car#796):
318    ///
319    /// - a **benchmark** can score it as a policy refusal instead of counting a
320    ///   crash, or silently inflating a pass rate by dropping it;
321    /// - a **retry loop** can stop, rather than burning its budget re-sending a
322    ///   decision that will never change;
323    /// - an **operator** can tell a content ruling from a misconfiguration.
324    ///
325    /// This says nothing about whether the refusal was *correct*. CAR is
326    /// reporting that something upstream declined the content, not endorsing the
327    /// call — an adversarial-safety suite is *supposed* to send input like this,
328    /// and a gateway that drops a variable fraction of it cannot be a substrate
329    /// for that measurement. Making the refusal legible is the part CAR owns.
330    #[error("{provider} refused this request on content grounds{}{}: {message}",
331        .kind.as_deref().map(|k| format!(" (type={k}")).unwrap_or_default(),
332        .code.as_deref().map(|c| format!(", code={c})")).unwrap_or_default())]
333    ContentRefused {
334        provider: String,
335        /// The gateway's own classification, when it sent one.
336        kind: Option<String>,
337        code: Option<String>,
338        message: String,
339    },
340
341    /// A managed gateway has no upstream configured for an entire namespace of
342    /// models it otherwise advertises.
343    ///
344    /// Environment-scoped, one level up from [`Self::ProviderAccount`]: the
345    /// account is fine and the credential is fine — the *deployment* was never
346    /// given an upstream to proxy to. Every model in the namespace fails it
347    /// identically, so none of them deserves the health penalty, and retrying
348    /// the next one down the fallback chain replays the same rejection.
349    ///
350    /// Kept distinct from `ProviderAccount` because the remedy is different and
351    /// belongs to a different person: an account rejection is the user's to fix
352    /// (top up credits, re-add a key), while this one is an operator
353    /// provisioning gap the user cannot act on at all. Collapsing them would
354    /// tell users to check a credential that is working.
355    ///
356    /// `namespace` is the model-id prefix the condition covers, so the dispatch
357    /// loop can drop every remaining candidate under it (Parslee-ai/car#786).
358    #[error("{provider} gateway has no upstream configured for '{namespace}' (HTTP {status}): {message}")]
359    GatewayUnconfigured {
360        provider: String,
361        namespace: String,
362        status: u16,
363        message: String,
364    },
365
366    #[error("tokenization error: {0}")]
367    TokenizationError(String),
368
369    #[error("device error: {0}")]
370    DeviceError(String),
371
372    #[error("io error: {0}")]
373    Io(#[from] std::io::Error),
374}
375
376impl From<resource_policy::LocalAdmissionError> for InferenceError {
377    fn from(error: resource_policy::LocalAdmissionError) -> Self {
378        let recovery = match error.preflight.verdict {
379            resource_policy::LocalLoadVerdict::DisabledByPolicy => {
380                "Local model loading is disabled by the 0 GB allocation. Increase Local Models RAM in Settings, or choose a remote model. Downloads remain available.".to_string()
381            }
382            resource_policy::LocalLoadVerdict::ExceedsConfiguredCeiling => format!(
383                "This model needs about {} MB for this request, beyond the configured {} MB local-model allocation. Increase the allocation or choose a smaller model.",
384                error.preflight.estimated_incremental_mb,
385                error.preflight.configured_ceiling_mb
386            ),
387            resource_policy::LocalLoadVerdict::InsufficientLiveMemory => format!(
388                "Not enough memory is free to start this model while preserving CAR's {} MB emergency reserve. Close memory-heavy apps or choose a smaller model.",
389                error.preflight.emergency_reserve_mb
390            ),
391            resource_policy::LocalLoadVerdict::LiveMemoryUnknown => {
392                "CAR could not measure live memory. The static allocation fits, but current safety is unknown.".to_string()
393            }
394            resource_policy::LocalLoadVerdict::ModelMaintenance => {
395                "This local model is being removed or maintained. Wait for that operation to finish, then retry.".to_string()
396            }
397            resource_policy::LocalLoadVerdict::PendingTeardown => {
398                "CAR is still confirming that the previous local model process exited. Wait for teardown to finish, then retry.".to_string()
399            }
400            resource_policy::LocalLoadVerdict::Allowed => error.to_string(),
401        };
402        Self::LocalResourceBlocked {
403            preflight: error.preflight,
404            recovery,
405        }
406    }
407}
408
409/// Whether a dispatch error should count against the model's circuit breaker.
410///
411/// Two classes are excluded because neither is evidence about the model:
412///
413/// - [`InferenceError::UnsupportedMode`] is a **deterministic capability
414///   mismatch** — a JsonSchema `response_format` on Anthropic, or a video/audio
415///   block on a text-only provider — that will fail identically every time on
416///   THIS model, while the model stays perfectly healthy for other traffic.
417///   Feeding it to the breaker would trip a healthy model out of rotation for
418///   ALL requests, not just the incompatible ones.
419/// - [`InferenceError::ProviderAccount`] is an **account-wide** rejection —
420///   a bad key, or no credits. Every model on that account fails it and no
421///   model deserves the blame; benching them would outlive the billing fix
422///   (Parslee-ai/car#650).
423/// - [`InferenceError::GatewayUnconfigured`] is **environment-wide** — the
424///   deployment has no upstream to proxy to, so every model in the namespace
425///   fails identically and none of them was ever given a chance. Measured cost
426///   of not excluding it: ten managed aliases sitting at 52 calls / 0 successes
427///   in `car models stats`, a health record earned entirely by a
428///   misconfiguration (Parslee-ai/car#786).
429///
430/// Every other error is a genuine availability/health signal and still counts.
431fn error_counts_against_circuit_breaker(e: &InferenceError) -> bool {
432    !matches!(
433        e,
434        InferenceError::UnsupportedMode { .. }
435            | InferenceError::ProviderAccount { .. }
436            | InferenceError::GatewayUnconfigured { .. }
437            // A content refusal is a ruling about the REQUEST, not evidence
438            // about the model — which handles the same payload correctly when
439            // it gets through. Benching a model for what a filter in front of
440            // it decided would make an adversarial-safety suite progressively
441            // evict the models it is trying to measure (Parslee-ai/car#796).
442            | InferenceError::ContentRefused { .. }
443            | InferenceError::CatalogPreconditionMismatch { .. }
444            | InferenceError::ControlledTermination
445    )
446}
447
448/// Whether this failure ends the fallback chain instead of advancing it.
449///
450/// Every other condition the dispatch loop handles is about a *lane* — a dead
451/// credential, an unconfigured namespace, a capability the model lacks — and the
452/// right move is to try a different one. A content refusal is about the
453/// **request**, which is the one thing the chain cannot vary: each remaining
454/// candidate replays the identical payload the filter just declined.
455///
456/// So falling through does not merely waste attempts, it answers dishonestly. A
457/// remote-only chain has an installed on-device model appended as a last resort
458/// (see [`should_append_local_last_resort`]), and nothing is filtering that one —
459/// so a refusal of `parslee/reasoning` comes back as a *local* model's answer
460/// attributed to the model the caller asked for. That is the "manufactures fake
461/// results" failure the `strict_model` carve-out already exists to prevent, and
462/// it is fatal to the case #796 was filed from: an adversarial-safety benchmark
463/// drives this path deliberately, so a silent model swap inflates its pass rate
464/// and its run-to-run counts stop being reproducible.
465///
466/// Ending the chain surfaces `ContentRefused`, which the daemon returns as
467/// JSON-RPC `-32007` — a ruling the harness can score instead of a crash it has
468/// to guess at (Parslee-ai/car#796).
469fn error_ends_fallback_chain(e: &InferenceError) -> bool {
470    matches!(
471        e,
472        InferenceError::ContentRefused { .. }
473            | InferenceError::CatalogPreconditionMismatch { .. }
474            | InferenceError::ControlledTermination
475    )
476}
477
478/// Apply the exhausted-chain recovery hints, which rewrite an opaque final error
479/// into one that names the two concrete things a user can do about it.
480///
481/// A content refusal is **exempt**. Both hints match on SUBSTRINGS of the Display
482/// text, and `ContentRefused` embeds the gateway's own message verbatim — so a
483/// refusal whose text happens to quote `403 forbidden` or `token expired` would
484/// be re-wrapped as `InferenceFailed` and lose its classification on the way out.
485/// That is the same drop-the-type mistake #796 was filed about, arriving one
486/// layer later. The variant already IS the answer here; there is nothing left to
487/// infer from its prose.
488fn apply_exhaustion_recovery_hint(underlying: InferenceError) -> InferenceError {
489    if matches!(
490        underlying,
491        InferenceError::ContentRefused { .. }
492            | InferenceError::CatalogPreconditionMismatch { .. }
493            | InferenceError::ControlledTermination
494    ) {
495        return underlying;
496    }
497    let underlying_str = underlying.to_string();
498    match no_backend_recovery_hint(&underlying_str)
499        .or_else(|| auth_expired_recovery_hint(&underlying_str))
500    {
501        Some(msg) => InferenceError::InferenceFailed(msg),
502        None => underlying,
503    }
504}
505
506const AUTH_LOGIN_MARKER: &str = "auth login";
507const AUTH_STORE_UNREADABLE_MARKER: &str = "credential store unreadable";
508const AUTH_ENV_MISSING_MARKER: &str = "credential environment variable missing";
509
510/// Every stable phrase which means a failure needs credential repair rather
511/// than an infrastructure retry. Route-level summaries use these same markers,
512/// and `is_auth_failure_message` is the workspace classifier that consumes the
513/// table.
514const AUTH_FAILURE_MESSAGE_MARKERS: &[&str] = &[
515    "no credential for proprietary",
516    AUTH_LOGIN_MARKER,
517    "session has expired",
518    "cannot read parslee credentials",
519    AUTH_STORE_UNREADABLE_MARKER,
520    AUTH_ENV_MISSING_MARKER,
521    // The two non-Parslee route summaries carry no `car auth login` remedy
522    // line, so without their own rows here they would render as credential
523    // failures the shared classifier calls infrastructure noise. Covers
524    // "{provider} credential was rejected for `x` (HTTP 401)" and
525    // "credential expired or was rejected for `x` — repair its provider login".
526    "credential was rejected",
527    "repair its provider login",
528];
529
530/// A credential failure from a configured or explicitly requested provider.
531///
532/// The fallback loop intentionally keeps trying after one account becomes
533/// unusable, but if every later candidate fails too, the final candidate's
534/// error is not the root cause the operator should fix first. Preserve the
535/// most recent actionable credential cause separately so a local resource
536/// error cannot overwrite an expired login in the final aggregate, while an
537/// ambient missing variable from an unconfigured fallback cannot overwrite
538/// the real terminal failure (Parslee-ai/car#1248).
539#[derive(Debug, Clone, PartialEq, Eq)]
540struct RouteCredentialFailure {
541    summary: String,
542    source_error: String,
543}
544
545fn parslee_signed_out_route_failure() -> RouteCredentialFailure {
546    let summary = "Parslee login is absent — run `car auth login` before retrying".to_string();
547    RouteCredentialFailure {
548        source_error: summary.clone(),
549        summary,
550    }
551}
552
553fn route_credential_failure(
554    candidate: &str,
555    error: &InferenceError,
556    promote_missing_credential: bool,
557) -> Option<String> {
558    match error {
559        InferenceError::CredentialUnavailable {
560            provider,
561            reason,
562            detail,
563            ..
564        } => {
565            let provider_name = if provider.eq_ignore_ascii_case("parslee") {
566                "Parslee".to_string()
567            } else {
568                provider.clone()
569            };
570            let summary = match reason {
571                CredentialFailure::Expired { .. } => format!(
572                    "{provider_name} login expired for `{candidate}` — run `car auth login`"
573                ),
574                CredentialFailure::SignedOut => format!(
575                    "{provider_name} login is absent for `{candidate}` — run `car auth login`"
576                ),
577                CredentialFailure::StoreUnreadable => format!(
578                    "{provider_name} {AUTH_STORE_UNREADABLE_MARKER} for `{candidate}` — unlock the credential store, then retry"
579                ),
580                CredentialFailure::EnvVarMissing { .. } if !promote_missing_credential => {
581                    return None;
582                }
583                CredentialFailure::EnvVarMissing { env_var } => format!(
584                    "{provider_name} {AUTH_ENV_MISSING_MARKER}: `{env_var}` for explicitly requested `{candidate}` — {detail}"
585                ),
586                // The authority re-read found a usable credential. This is a
587                // retry signal, not evidence that a person must repair auth.
588                CredentialFailure::RaceRetryable => return None,
589            };
590            Some(summary)
591        }
592        InferenceError::ProviderAccount {
593            provider, status, ..
594        } if matches!(*status, 401 | 403) => {
595            if provider.eq_ignore_ascii_case("parslee") {
596                Some(format!(
597                    "Parslee login expired or was rejected for `{candidate}` — run `car auth login`"
598                ))
599            } else {
600                Some(format!(
601                    "{provider} credential was rejected for `{candidate}` (HTTP {status})"
602                ))
603            }
604        }
605        _ => {
606            let rendered = error.to_string();
607            let lower = rendered.to_ascii_lowercase();
608            if is_auth_rejection_message(&rendered) {
609                if candidate
610                    .split_once('/')
611                    .is_some_and(|(provider, _)| provider.eq_ignore_ascii_case("parslee"))
612                {
613                    Some(format!(
614                        "Parslee login expired or was rejected for `{candidate}` — run `car auth login`"
615                    ))
616                } else {
617                    Some(format!(
618                        "credential expired or was rejected for `{candidate}` — repair its provider login"
619                    ))
620                }
621            } else if lower.contains(AUTH_STORE_UNREADABLE_MARKER)
622                || (promote_missing_credential && lower.contains("keychain lookup failed"))
623            {
624                Some(format!(
625                    "{AUTH_STORE_UNREADABLE_MARKER} for `{candidate}` — unlock the credential store, then retry"
626                ))
627            } else {
628                None
629            }
630        }
631    }
632}
633
634fn record_route_credential_failure(
635    slot: &mut Option<RouteCredentialFailure>,
636    candidate: &str,
637    error: &InferenceError,
638    promote_missing_credential: bool,
639) {
640    if let Some(summary) = route_credential_failure(candidate, error, promote_missing_credential) {
641        // Last actionable credential failure wins. In preference order it is
642        // the terminal configured/attempted provider, while steady-state
643        // EnvVarMissing noise from an unconfigured fallback never enters the
644        // slot unless that provider was explicitly requested.
645        *slot = Some(RouteCredentialFailure {
646            summary,
647            source_error: error.to_string(),
648        });
649    }
650}
651
652/// The routing snapshot probes only Parslee's credential store. Preserve its
653/// failure only when a Parslee route participates; another provider's API key
654/// cannot be repaired by signing in to Parslee. Actual attempted provider auth
655/// failures are recorded separately by `record_route_credential_failure`.
656fn chain_includes_parslee_route<'a>(
657    mut resolve: impl FnMut(&str) -> Option<&'a ModelSchema>,
658    chain: &[String],
659) -> bool {
660    chain.iter().any(|candidate| {
661        resolve(candidate).is_some_and(|schema| schema.provider.eq_ignore_ascii_case("parslee"))
662    })
663}
664
665/// Apply a remembered route-level credential cause before the exhausted-chain
666/// hint. The credential summary is deliberately first and the final
667/// candidate's error remains secondary detail, so mixed auth + OOM failures
668/// tell the operator to repair the login rather than resize a local model.
669///
670/// The underlying error keeps its TYPE. Downstream consumers branch on the
671/// variant, not the prose — `car run-task` re-runs an
672/// [`InferenceError::Transient`], account-wide handling keys on
673/// [`InferenceError::ProviderAccount`], and
674/// [`InferenceError::CredentialUnavailable`] carries [`CredentialFailure`] as
675/// data — so the credential context is folded into the variant's human-facing
676/// message field instead of collapsing everything to `InferenceFailed`. Only
677/// variants with no augmentable message field fall back to a stringified
678/// `InferenceFailed`.
679///
680/// A fresh install exhausts with "no models / no backend" errors; the
681/// credential cause does not replace [`no_backend_recovery_hint`]'s setup
682/// guidance, because an operator who cannot log in still needs the on-device
683/// `car models pull` path.
684fn apply_route_failure_context(
685    underlying: InferenceError,
686    credential: Option<&RouteCredentialFailure>,
687) -> InferenceError {
688    if matches!(
689        underlying,
690        InferenceError::ContentRefused { .. }
691            | InferenceError::CatalogPreconditionMismatch { .. }
692            | InferenceError::ControlledTermination
693    ) {
694        return underlying;
695    }
696    let Some(credential) = credential else {
697        return apply_exhaustion_recovery_hint(underlying);
698    };
699    let rendered = underlying.to_string();
700    let summary = credential.summary.as_str();
701    let setup_hint = no_backend_recovery_hint(&rendered);
702    let connective = if rendered == credential.source_error {
703        "provider detail"
704    } else {
705        "fallback then failed"
706    };
707    let augment = |field: String| match &setup_hint {
708        // The hint already embeds the underlying error verbatim.
709        Some(hint) => format!("{summary}; {hint}"),
710        None => format!("{summary}; {connective}: {field}"),
711    };
712    match underlying {
713        InferenceError::InferenceFailed(message) => {
714            InferenceError::InferenceFailed(augment(message))
715        }
716        InferenceError::Transient { status, message } => InferenceError::Transient {
717            status,
718            message: augment(message),
719        },
720        InferenceError::ProviderAccount {
721            provider,
722            status,
723            message,
724        } => InferenceError::ProviderAccount {
725            provider,
726            status,
727            message: augment(message),
728        },
729        InferenceError::CredentialUnavailable {
730            provider,
731            model,
732            reason,
733            detail,
734        } => InferenceError::CredentialUnavailable {
735            provider,
736            model,
737            reason,
738            detail: augment(detail),
739        },
740        InferenceError::GatewayUnconfigured {
741            provider,
742            namespace,
743            status,
744            message,
745        } => InferenceError::GatewayUnconfigured {
746            provider,
747            namespace,
748            status,
749            message: augment(message),
750        },
751        InferenceError::LocalResourceBlocked {
752            preflight,
753            recovery,
754        } => InferenceError::LocalResourceBlocked {
755            preflight,
756            recovery: augment(recovery),
757        },
758        // No message field to fold the context into — the stringified wrap is
759        // the only remaining honest rendering.
760        _ => InferenceError::InferenceFailed(augment(rendered)),
761    }
762}
763
764/// Why a credential was unusable, as data — see
765/// [`InferenceError::CredentialUnavailable`].
766///
767/// These need *different remedies*, which is the whole reason they are
768/// separated: re-authenticating fixes `SignedOut` and `Expired`, does nothing
769/// for `StoreUnreadable` (unlock the keychain), and is the wrong advice
770/// entirely for `EnvVarMissing` (set the variable). A long job that dies on one
771/// while being told to do the other is Parslee-ai/car#797.
772#[derive(Debug, Clone, PartialEq, Eq)]
773pub enum CredentialFailure {
774    /// A Parslee session existed and its access token aged out; refresh did not
775    /// yield a new one. `expires_at` is unix seconds.
776    ///
777    /// The distinguishing case from #797: the account is *fine*, it is the run
778    /// that outlived the token. Consumers that can checkpoint should treat this
779    /// as resumable-after-reauth rather than as a hard configuration error.
780    Expired { expires_at: u64 },
781    /// A published tombstone: no account is active. The only state that
782    /// genuinely means "log in".
783    SignedOut,
784    /// The credential store could not be read (locked keychain, helper
785    /// timeout). Says nothing about whether credentials exist — notably NOT a
786    /// sign-out, and re-authenticating is the wrong reflex.
787    StoreUnreadable,
788    /// A plain env/keychain-backed provider whose variable did not resolve.
789    EnvVarMissing { env_var: String },
790    /// The store reported an active session on the failure-path re-read — a
791    /// race between the two reads, so the request is worth retrying.
792    RaceRetryable,
793}
794
795/// Which device to run inference on.
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797pub enum Device {
798    Cpu,
799    Metal,
800    Cuda(usize), // device ordinal
801}
802
803impl Device {
804    /// Auto-detect the best available device for this platform.
805    ///
806    /// macOS uses MLX (Metal); x86_64 Linux and Windows are compiled with
807    /// candle CUDA, so we prefer the GPU there and let `to_candle_device`'s
808    /// `cuda_if_available` transparently fall back to CPU on a box with no
809    /// NVIDIA GPU. aarch64 Linux and other targets run CPU candle. See
810    /// `project_local_inference_gpu_only`.
811    pub fn auto() -> Self {
812        #[cfg(all(target_os = "macos", feature = "metal"))]
813        {
814            return Device::Metal;
815        }
816        #[cfg(all(
817            any(target_os = "linux", target_os = "windows"),
818            target_arch = "x86_64",
819            not(car_skip_cuda)
820        ))]
821        {
822            return Device::Cuda(0);
823        }
824        #[cfg(not(any(
825            all(target_os = "macos", feature = "metal"),
826            all(
827                any(target_os = "linux", target_os = "windows"),
828                target_arch = "x86_64",
829                not(car_skip_cuda)
830            )
831        )))]
832        {
833            Device::Cpu
834        }
835    }
836}
837
838/// Configuration for the inference engine.
839#[derive(Debug, Clone)]
840pub struct InferenceConfig {
841    /// Where to store downloaded models. Defaults to ~/.car/models/
842    ///
843    /// This is a machine-global weight cache, NOT per-daemon state: it stays
844    /// shared even when `CAR_HOME` relocates a daemon. Anything CAR *writes*
845    /// about itself belongs under [`state_root`](Self::state_root) instead.
846    pub models_dir: std::path::PathBuf,
847    /// CAR state root: `$CAR_HOME`, else `~/.car`.
848    ///
849    /// Everything the engine persists about itself — the routing outcome
850    /// profiles, the receipt ledger, key-pool stats, the signed-catalog cache,
851    /// the discovery cache, the user `models.json` — hangs off this. Two
852    /// daemons with different roots therefore keep separate bookkeeping while
853    /// still sharing the multi-gigabyte weights in `models_dir`. With `CAR_HOME`
854    /// unset this is `~/.car`, exactly where all of those files already are.
855    pub state_root: std::path::PathBuf,
856    /// Device override. None = auto-detect.
857    pub device: Option<Device>,
858    /// Default model for generation tasks.
859    pub generation_model: String,
860    /// Optional preferred model override for generation tasks.
861    pub preferred_generation_model: Option<String>,
862    /// Default model for embedding tasks.
863    pub embedding_model: String,
864    /// Optional preferred model override for embedding tasks.
865    pub preferred_embedding_model: Option<String>,
866    /// Default model for classification tasks.
867    pub classification_model: String,
868    /// Optional preferred model override for classification tasks.
869    pub preferred_classification_model: Option<String>,
870}
871
872impl Default for InferenceConfig {
873    fn default() -> Self {
874        // `models_dir` is deliberately anchored at `$HOME/.car`, NOT at the
875        // `car_home` state root: model weights (and the python runtimes beside
876        // them) are a multi-gigabyte machine-global cache of byte-identical
877        // files, not per-instance state. A daemon relocated with `CAR_HOME`
878        // moves its own journals, prefs and caches, and goes on sharing these —
879        // the alternative is re-downloading tens of gigabytes to end up with
880        // the same bytes in a second place.
881        //
882        // Only the weights get that treatment. Everything the engine writes
883        // about *itself* — the outcome profiles and receipt ledger, key-pool
884        // stats, the signed-catalog cache, the discovery cache, the user
885        // `models.json` — resolves under `state_root` below, so a relocated
886        // daemon keeps its own copy. Several of those files historically sat
887        // inside `models/`; they still do, just under `state_root/models`
888        // rather than under the weights dir, which is the same directory
889        // whenever `CAR_HOME` is unset.
890        let models_dir = default_models_dir();
891
892        let hw = HardwareInfo::detect();
893
894        Self {
895            models_dir,
896            state_root: car_home::root_or_relative(),
897            device: None,
898            generation_model: hw.recommended_model,
899            preferred_generation_model: None,
900            embedding_model: "Qwen3-Embedding-0.6B".to_string(),
901            preferred_embedding_model: None,
902            classification_model: "Qwen3-0.6B".to_string(),
903            preferred_classification_model: None,
904        }
905    }
906}
907
908impl InferenceConfig {
909    /// Engine state that has always lived beside the weights, kept at the same
910    /// relative path (`models/`) but anchored at
911    /// [`state_root`](Self::state_root): the outcome profiles, the receipt
912    /// ledger, key-pool stats, benchmark priors, the discovery cache.
913    ///
914    /// Identical to [`models_dir`](Self::models_dir) whenever `CAR_HOME` is
915    /// unset — which is why no existing install's files move — and a separate,
916    /// per-daemon directory once it is set.
917    pub fn state_models_dir(&self) -> std::path::PathBuf {
918        self.state_root.join("models")
919    }
920}
921
922/// The machine-shared model-weight cache: `$HOME/.car/models`
923/// (`$USERPROFILE` on Windows), cwd-relative only when neither resolves.
924///
925/// Deliberately NOT `car_home`-anchored — see [`InferenceConfig::models_dir`].
926/// Exposed so `car doctor` can check the weights the running daemon actually
927/// loads, rather than the (possibly relocated) state root's `models/`.
928pub fn default_models_dir() -> std::path::PathBuf {
929    dirs_next()
930        .unwrap_or_else(|| std::path::PathBuf::from("."))
931        .join(".car")
932        .join("models")
933}
934
935fn dirs_next() -> Option<std::path::PathBuf> {
936    // `HOME`, falling back to `USERPROFILE` on Windows (where `HOME` is normally
937    // unset) — the same fallback used across the workspace. Without it every
938    // `InferenceConfig::default()` resolves `models_dir` CWD-relative on Windows.
939    std::env::var_os("HOME")
940        .or_else(|| std::env::var_os("USERPROFILE"))
941        .map(std::path::PathBuf::from)
942}
943
944fn model_source_identity(schema: &ModelSchema) -> &str {
945    match &schema.source {
946        ModelSource::Local { hf_repo, .. }
947        | ModelSource::Mlx { hf_repo, .. }
948        | ModelSource::ManagedVllmMlx { hf_repo, .. } => hf_repo,
949        ModelSource::WhisperCpp { model } | ModelSource::CodexCli { model } => model,
950        _ => &schema.id,
951    }
952}
953
954/// Token usage statistics from a model response.
955#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
956pub struct TokenUsage {
957    /// Number of tokens in the prompt/input.
958    ///
959    /// For providers with prompt caching (Anthropic), this is the
960    /// *non-cached* prefix only — the tokens after the last cache
961    /// breakpoint. The cached portion is reported separately in
962    /// [`Self::cache_read_input_tokens`] / [`Self::cache_creation_input_tokens`],
963    /// so the true input total is the sum of all three. Pricing those
964    /// three buckets at the same rate over- or under-counts cost; see
965    /// [`crate::outcome::ModelProfile::usd_per_success`].
966    pub prompt_tokens: u64,
967    /// Number of tokens in the completion/output.
968    pub completion_tokens: u64,
969    /// Total tokens (prompt + completion).
970    pub total_tokens: u64,
971    /// Model's maximum context window size.
972    pub context_window: u64,
973    /// Prompt-cache hit: input tokens read from a previously written cache
974    /// entry. Billed at ~0.1× the base input rate. `0` when the provider
975    /// has no prompt caching, caching was disabled, or nothing hit.
976    /// (Anthropic `usage.cache_read_input_tokens`.)
977    #[serde(default)]
978    pub cache_read_input_tokens: u64,
979    /// Prompt-cache write: input tokens written into the cache this request.
980    /// Billed at ~1.25× (5-minute TTL) or ~2× (1-hour TTL) the base input
981    /// rate. `0` when caching is off or nothing was written.
982    /// (Anthropic `usage.cache_creation_input_tokens`.)
983    #[serde(default)]
984    pub cache_creation_input_tokens: u64,
985}
986
987/// Result of an inference call, including trace ID for outcome tracking.
988#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
989pub struct InferenceModelIdentity {
990    /// Exact immutable model id supplied through the protocol `model_id` pin.
991    /// `None` for adaptive routing and legacy display-name/alias requests.
992    #[serde(default)]
993    pub requested_model_id: Option<String>,
994    /// Canonical immutable catalog id actually used after routing/fallback.
995    #[serde(default)]
996    pub resolved_model_id: String,
997    /// SHA-256 digest of the resolved immutable `ModelSchema` row.
998    #[serde(default)]
999    pub row_digest: String,
1000    /// SHA-256 revision of the exact catalog snapshot captured for this call.
1001    #[serde(default)]
1002    pub catalog_revision: String,
1003}
1004
1005/// Result of an inference call, including trace ID for outcome tracking.
1006#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1007pub struct InferenceResult {
1008    /// The generated text (empty if tool_calls are present).
1009    pub text: String,
1010    /// Tool calls returned by the model (when tools were provided in the request).
1011    pub tool_calls: Vec<crate::tasks::generate::ToolCall>,
1012    /// Structured bounding boxes when the model emitted Qwen2.5-VL
1013    /// grounding spans (`<|box_*|>`, `<|object_ref_*|>`) in its text.
1014    /// Parsed from the same `text` field — the raw span markers remain
1015    /// visible in `text` for callers that need to see them verbatim.
1016    /// Empty vec when the model didn't ground anything (typical for
1017    /// non-VL models or prompts that only ask for description).
1018    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1019    pub bounding_boxes: Vec<crate::tasks::grounding::BoundingBox>,
1020    /// Trace ID for reporting outcomes back to the tracker.
1021    pub trace_id: String,
1022    /// Which model was used. Exact catalog-id pins report the immutable
1023    /// resolved id; legacy/adaptive routes retain their display-name behavior.
1024    pub model_used: String,
1025    /// Immutable catalog identity, flattened onto the v3 inference result so
1026    /// callers can validate the route without another lookup.
1027    #[serde(flatten)]
1028    pub model_identity: InferenceModelIdentity,
1029    /// Wall-clock latency in ms.
1030    pub latency_ms: u64,
1031    /// Time to first token in milliseconds. Populated by the local
1032    /// generate paths (Candle/MLX) which observe the prefill→first-decode
1033    /// transition directly. `None` for paths that can't measure it
1034    /// honestly without streaming — currently the non-streaming remote
1035    /// paths. Callers needing TTFT on remote models should use
1036    /// [`InferenceEngine::generate_tracked_stream`] and time the first
1037    /// `text` event arrival themselves.
1038    ///
1039    /// Always serialized (as `null` when `None`) so downstream
1040    /// validation harnesses can distinguish "wasn't measured" from
1041    /// "field doesn't exist on this client's protocol version".
1042    #[serde(default)]
1043    pub time_to_first_token_ms: Option<u64>,
1044    /// Token usage for the call. Populated by the remote providers from their
1045    /// API response, and by the local backends from their own decode loops —
1046    /// the in-process MLX and candle paths report the post-truncation prompt
1047    /// length and the number of tokens they sampled, and the mlx-vlm CLI path
1048    /// reports the counts the CLI prints (image patches included).
1049    ///
1050    /// `None` means nobody could report a count, and it is deliberately not a
1051    /// zeroed struct: a consumer summing `total_tokens` cannot tell a
1052    /// fabricated `0` from a real "this used no tokens", so an absent count is
1053    /// the honest answer and lets callers fall back to their own estimator
1054    /// (Parslee-ai/car#795). Still `None` on: FoundationModels (Apple's
1055    /// on-device framework exposes no token counts), a delegated runner that
1056    /// emits no `usage` stream event, and an mlx-vlm build whose performance
1057    /// summary doesn't parse.
1058    ///
1059    /// [`TokenUsage::context_window`] is `0` on the streaming path — the
1060    /// accumulator builds usage from stream events, which carry no model
1061    /// metadata. Non-streaming calls populate it.
1062    pub usage: Option<TokenUsage>,
1063    /// Provider-specific output items the protocol emitted alongside
1064    /// the response — currently used by the OpenAI Responses API to
1065    /// return reasoning blobs, encrypted_content, web-search results,
1066    /// etc. as opaque structured items the next request must include
1067    /// verbatim. Empty for protocols that don't emit them (Chat
1068    /// Completions, Anthropic, Gemini, all local backends).
1069    ///
1070    /// Callers carry these between turns by emitting them as a
1071    /// [`tasks::generate::Message::ProviderOutputItems`] message in
1072    /// the next request. Builder paths that don't recognize the
1073    /// originating protocol drop the variant — the items are
1074    /// protocol-specific and have no portable rendering.
1075    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1076    pub provider_output_items: Vec<serde_json::Value>,
1077    /// Extended-thinking blocks the model produced this turn (Anthropic adaptive
1078    /// thinking). Captured verbatim (text + opaque signature) so the caller can
1079    /// attach them to the replayed
1080    /// [`tasks::generate::Message::Assistant`] and preserve them on the next
1081    /// turn — Anthropic 400s if prior thinking blocks aren't sent back
1082    /// unchanged before the tool_use blocks. Empty for providers/models without
1083    /// thinking (Chat Completions, Gemini, all local backends).
1084    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1085    pub thinking: Vec<crate::tasks::generate::ThinkingBlock>,
1086    /// Why generation stopped. For remote models this is the raw
1087    /// provider string (OpenAI `finish_reason`, Anthropic `stop_reason`,
1088    /// Google `finishReason`). For local Qwen3 hybrid-thinking models the
1089    /// runtime also sets it for its reasoning-recovery path
1090    /// (car-releases#60): `"thinking_recovered"` when reasoning consumed
1091    /// the whole token budget inside an unclosed `<think>` block and the
1092    /// runtime retried with reasoning suppressed to produce a direct
1093    /// answer, or `"thinking_truncated"` when even that retry was empty.
1094    /// A model decoded in-process also reports
1095    /// `"local_decode_timeout"` ([`LOCAL_DECODE_TIMEOUT_STOP_REASON`]) when the
1096    /// wall-clock ceiling cut the pass short (car#851).
1097    /// `None` for an ordinary local completion or a provider that didn't
1098    /// report one. Always serialized (as `null` when `None`) so the wire
1099    /// contract is stable — see the `inference_result_serializes_*` tests.
1100    /// Use [`InferenceResult::was_truncated`] to detect a cut-short response.
1101    #[serde(default)]
1102    pub stop_reason: Option<String>,
1103    /// The candidate that was skipped because its credential was REJECTED
1104    /// (not merely absent), when a later candidate in the fallback chain
1105    /// then succeeded. `None` on the common path.
1106    ///
1107    /// Exists so a caller can ANNOUNCE the degrade instead of silently
1108    /// serving a different model: an operator whose Parslee sign-in lapsed
1109    /// otherwise sees a working run on a fallback backbone with no hint
1110    /// that the lane they configured is dead (Parslee-ai/car#888).
1111    #[serde(default, skip_serializing_if = "Option::is_none")]
1112    pub auth_fallback_from: Option<String>,
1113    /// True when this turn was served by the installed on-device model that
1114    /// CAR appended behind an otherwise remote-only fallback chain.
1115    ///
1116    /// This is distinct from merely using a local model: an explicitly chosen
1117    /// local primary is ordinary routing. Callers should surface this marker so
1118    /// a resilience fallback cannot masquerade as the preferred remote model.
1119    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1120    pub local_last_resort: bool,
1121    /// Every candidate the chain moved past, in order, and WHY. Empty when the
1122    /// first candidate served.
1123    ///
1124    /// The general form of [`InferenceResult::auth_fallback_from`], which
1125    /// answers only "was a credential rejected". A run whose backbone changed
1126    /// mid-session because of a rate limit, a timeout, or an absent credential
1127    /// had no reason recorded anywhere at all — so a surprising result could be
1128    /// attributed to the code under test when the real cause was that a
1129    /// different model wrote it (Parslee-ai/car#1351).
1130    ///
1131    /// **Not a superset of `auth_fallback_from`, even though it holds every
1132    /// hop.** [`FallbackReason::CredentialRejected`] is deliberately broader
1133    /// than that field's predicate: it includes a provider refusing an API key
1134    /// (`ProviderAccount` 401), whose remedy is to fix the key.
1135    /// `auth_fallback_from` names only the narrower set a person clears by
1136    /// signing in, because the announcement it drives says `car auth login` —
1137    /// and telling someone to sign in over a bad OpenAI key is the wrong
1138    /// remedy (Parslee-ai/car#888). Recording both keeps the journal general
1139    /// without making the announcement wrong.
1140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1141    pub fallback_from: Vec<FallbackFrom>,
1142}
1143
1144/// A candidate the fallback chain moved past, and why.
1145#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1146pub struct FallbackFrom {
1147    /// The candidate's name, as the chain knew it.
1148    pub candidate: String,
1149    pub reason: FallbackReason,
1150}
1151
1152/// Why the chain moved past a candidate.
1153///
1154/// Coarse on purpose. The point is to distinguish causes an operator would ACT
1155/// on differently — sign in, wait, configure a key, look at the provider — not
1156/// to reproduce every provider's error taxonomy. Anything unrecognized is
1157/// [`FallbackReason::Failed`] rather than being forced into a bucket it does
1158/// not belong in.
1159#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1160#[serde(rename_all = "snake_case")]
1161pub enum FallbackReason {
1162    /// A credential exists and was REFUSED — an expired Parslee session, or a
1163    /// provider rejecting an API key (`ProviderAccount` 401/403).
1164    ///
1165    /// Deliberately broader than [`InferenceResult::auth_fallback_from`]'s
1166    /// predicate, which names only the subset a person clears by signing in.
1167    /// `car auth login` does not fix a bad OpenAI key, so this variant must not
1168    /// be read as "run that" — the remedy depends on which credential was
1169    /// refused.
1170    CredentialRejected,
1171    /// No credential is configured for that lane at all. A different fix from
1172    /// `CredentialRejected`: nothing expired, nothing was ever set.
1173    CredentialAbsent,
1174    /// The provider rate-limited the call (429 / "too many requests"). Clears
1175    /// by waiting.
1176    RateLimited,
1177    /// The account is out of credits or over quota (402).
1178    ///
1179    /// Separate from [`FallbackReason::RateLimited`] because an empty balance
1180    /// does NOT clear by waiting — the remedy is to top up. Folding it in told
1181    /// an operator to wait out a billing problem.
1182    QuotaExhausted,
1183    /// The call exceeded its deadline.
1184    ///
1185    /// Only when a deadline is what was actually hit. A transport failure with
1186    /// no status — connection refused, DNS, TLS, a truncated body — is
1187    /// [`FallbackReason::Failed`], because telling someone their call timed out
1188    /// when the endpoint was never up sends them to raise a timeout instead of
1189    /// starting the runtime.
1190    TimedOut,
1191    /// Anything else — a 5xx, a malformed request, a panicked runner, an
1192    /// unreadable credential store. The honest bucket, and it has to STAY
1193    /// honest: a first version of this classifier matched substrings and
1194    /// silently absorbed every `ProviderAccount` rejection here, which is the
1195    /// opposite of what this variant is for.
1196    Failed,
1197}
1198
1199/// Classify a candidate's failure into a [`FallbackReason`].
1200///
1201/// **On the TYPED error, not on its rendered prose.** Every discriminating fact
1202/// here already exists as data — `ProviderAccount` carries `status: u16`,
1203/// `CredentialUnavailable` carries `reason: CredentialFailure`, `Transient`
1204/// carries `status: Option<u16>` — and `CredentialFailure`'s own doc says why
1205/// (car#797): a consumer wanting to branch on "token aged out mid-run" versus
1206/// "never signed in" had to substring-match English that could be reworded at
1207/// any time.
1208///
1209/// A first version of this DID substring-match the English, and got four things
1210/// wrong that this file had already fixed twice elsewhere:
1211///
1212/// - `ProviderAccount` 401 renders `"… account rejected the request (HTTP 401):
1213///   provider rejected the API key …"` — no "unauthorized" token, so the
1214///   substring rule missed it and a REFUSED API KEY, the most actionable
1215///   degrade there is, journaled as `Failed`;
1216/// - a 400 whose provider body merely quotes "401 Unauthorized" classified as
1217///   `CredentialRejected`, sending an operator to re-auth over a malformed
1218///   request. `is_provider_transient` anchors on [`parse_api_returned_status`]
1219///   for exactly this reason, and `remote::is_auth_rejection` anchors on
1220///   "HTTP 401" with a test pinning the quoting case;
1221/// - `Parslee org lookup failed: HTTP 429 …` classified as `CredentialRejected`,
1222///   though the producer deliberately gates `note_credential_rejected()` on
1223///   401/403 because a 429 is not a dead credential;
1224/// - a locked keychain (`CredentialFailure::StoreUnreadable`) classified as
1225///   `CredentialAbsent`, whose remedy — configure a key — is wrong for a
1226///   credential that exists and cannot be read.
1227///
1228/// Only [`InferenceError::InferenceFailed`] falls through to a string sniff,
1229/// because it is the one variant carrying no structure, and that sniff is
1230/// status-anchored rather than substring-anchored.
1231pub fn classify_fallback_reason(error: &InferenceError) -> FallbackReason {
1232    use FallbackReason as R;
1233    match error {
1234        // 401/403 = the key was refused; 402 = the account is out of credit,
1235        // which is a "wait / top up" not a "your credential is wrong".
1236        InferenceError::ProviderAccount { status, .. } => match status {
1237            402 => R::QuotaExhausted,
1238            _ => R::CredentialRejected,
1239        },
1240        // Branch on the machine-readable classification, per its own contract.
1241        InferenceError::CredentialUnavailable { reason, .. } => match reason {
1242            // The account is fine; the run outlived the token.
1243            CredentialFailure::Expired { .. } => R::CredentialRejected,
1244            // Nothing was rejected — there is simply nothing there.
1245            CredentialFailure::SignedOut | CredentialFailure::EnvVarMissing { .. } => {
1246                R::CredentialAbsent
1247            }
1248            // Says NOTHING about whether a credential exists. Neither absent
1249            // nor rejected, so the honest bucket beats a wrong remedy.
1250            CredentialFailure::StoreUnreadable | CredentialFailure::RaceRetryable => R::Failed,
1251        },
1252        InferenceError::Transient {
1253            status: Some(429), ..
1254        } => R::RateLimited,
1255        // "Transport OR timeout", per the variant's own doc — and the two are
1256        // not distinguishable here. `reqwest_error_is_transient` admits
1257        // connect-refused, DNS, TLS and truncated-body errors alongside real
1258        // timeouts, and `TransportAttemptError` drops the `is_timeout` flag
1259        // that would separate them. Calling all of that `TimedOut` tells an
1260        // operator whose local runtime never started to raise a timeout.
1261        InferenceError::Transient { status: None, .. } => R::Failed,
1262        InferenceError::Transient { .. } => R::Failed,
1263        // The one case that IS unambiguously a deadline: the caller's own
1264        // armed `infer.deadline` elapsed, and the error says which one.
1265        InferenceError::DeadlineExceeded { .. } => R::TimedOut,
1266        InferenceError::InferenceFailed(msg) => classify_untyped_failure(msg),
1267        _ => R::Failed,
1268    }
1269}
1270
1271/// The string fallback for [`InferenceError::InferenceFailed`], which carries
1272/// no structure.
1273///
1274/// Status-anchored wherever a status exists. A provider's error body is text we
1275/// did not write: it can quote any number or phrase, and
1276/// `apply_exhaustion_recovery_hint` already exempts `ContentRefused` from
1277/// substring hints for precisely that reason.
1278fn classify_untyped_failure(msg: &str) -> FallbackReason {
1279    use FallbackReason as R;
1280    // `API returned <status>: <verbatim provider body>` — trust the status,
1281    // never the body.
1282    if let Some(status) = parse_api_returned_status(msg) {
1283        return match status {
1284            401 | 403 => R::CredentialRejected,
1285            402 => R::QuotaExhausted,
1286            429 => R::RateLimited,
1287            408 | 504 => R::TimedOut,
1288            _ => R::Failed,
1289        };
1290    }
1291    let l = msg.to_ascii_lowercase();
1292    // `Parslee org lookup failed: HTTP <status>: <body>` is emitted for ANY
1293    // non-success status, so the phrase alone does not mean a dead credential.
1294    if l.contains("org lookup failed") {
1295        return if l.contains("http 401") || l.contains("http 403") {
1296            R::CredentialRejected
1297        } else if l.contains("http 429") {
1298            R::RateLimited
1299        } else {
1300            R::Failed
1301        };
1302    }
1303    if l.contains("authentication required")
1304        || l.contains("invalid_grant")
1305        || l.contains("token expired")
1306    {
1307        return R::CredentialRejected;
1308    }
1309    if l.contains("no credential for proprietary") || l.contains("no api key") {
1310        return R::CredentialAbsent;
1311    }
1312    if l.contains("too many requests") || l.contains("rate limit") {
1313        return R::RateLimited;
1314    }
1315    if l.contains("timed out") || l.contains("deadline exceeded") {
1316        return R::TimedOut;
1317    }
1318    R::Failed
1319}
1320
1321/// Append `candidate` to the chain's hop list, with why it was skipped.
1322///
1323/// **Every hop, in order** — not first-wins. A chain that skips lanes 1, 2 and 3
1324/// before lane 4 serves made three transitions, and a single slot records one of
1325/// them while the journal downstream claims to hold every transition.
1326pub fn record_fallback_from(hops: &mut Vec<FallbackFrom>, candidate: &str, error: &InferenceError) {
1327    hops.push(FallbackFrom {
1328        candidate: candidate.to_string(),
1329        reason: classify_fallback_reason(error),
1330    });
1331}
1332
1333/// Handle returned by [`InferenceEngine::generate_tracked_stream`]: the event
1334/// receiver plus the stream-level metadata a caller needs to attribute the
1335/// finished turn. `trace_id` is the same trace the tap task resolves on
1336/// completion, so a caller can score the turn against it; `model_used` is the
1337/// resolved model. In-process only (the receiver isn't serializable) — the wire
1338/// layer forwards events and surfaces these fields on the final response itself.
1339pub struct TrackedStream {
1340    /// Resolved model id for this stream.
1341    pub model_used: String,
1342    /// Trace id (minted before the first token) the tap resolves on completion.
1343    pub trace_id: String,
1344    /// The forwarded event stream.
1345    pub events: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
1346}
1347
1348struct AbortOnDropTask<T>(Option<tokio::task::JoinHandle<T>>);
1349
1350impl<T> AbortOnDropTask<T> {
1351    async fn join(mut self) -> Result<T, tokio::task::JoinError> {
1352        self.0.take().expect("owned task handle available").await
1353    }
1354}
1355
1356impl<T> Drop for AbortOnDropTask<T> {
1357    fn drop(&mut self) {
1358        if let Some(task) = self.0.take() {
1359            task.abort();
1360        }
1361    }
1362}
1363
1364fn bound_model_identity(
1365    snapshot: &CatalogSnapshot,
1366    requested_model_id: Option<&str>,
1367    resolved_model_id: &str,
1368) -> Result<InferenceModelIdentity, InferenceError> {
1369    let row = snapshot.model_by_exact_id(resolved_model_id).ok_or_else(|| {
1370        InferenceError::InferenceFailed(format!(
1371            "resolved model `{resolved_model_id}` was absent from the catalog snapshot bound to this request"
1372        ))
1373    })?;
1374    Ok(InferenceModelIdentity {
1375        requested_model_id: requested_model_id.map(str::to_string),
1376        resolved_model_id: row.model.id.clone(),
1377        row_digest: row.row_digest.clone(),
1378        catalog_revision: snapshot.catalog_revision.clone(),
1379    })
1380}
1381
1382fn validate_expected_catalog_revision(
1383    req: &GenerateRequest,
1384    snapshot: &CatalogSnapshot,
1385) -> Result<(), InferenceError> {
1386    if let Some(expected) = req.expected_catalog_revision.as_deref() {
1387        if expected != snapshot.catalog_revision {
1388            return Err(InferenceError::CatalogPreconditionMismatch {
1389                detail: format!(
1390                    "expected catalog revision {expected}, got {}",
1391                    snapshot.catalog_revision
1392                ),
1393            });
1394        }
1395    }
1396    Ok(())
1397}
1398
1399fn validate_expected_catalog_row(
1400    req: &GenerateRequest,
1401    snapshot: &CatalogSnapshot,
1402    resolved_model_id: &str,
1403) -> Result<(), InferenceError> {
1404    let Some(expected) = req.expected_row_digest.as_deref() else {
1405        return Ok(());
1406    };
1407    let row = snapshot
1408        .model_by_exact_id(resolved_model_id)
1409        .ok_or_else(|| InferenceError::CatalogPreconditionMismatch {
1410            detail: format!(
1411                "resolved model `{resolved_model_id}` is absent from the bound catalog snapshot"
1412            ),
1413        })?;
1414    if expected != row.row_digest {
1415        return Err(InferenceError::CatalogPreconditionMismatch {
1416            detail: format!(
1417                "expected row digest {expected} for `{resolved_model_id}`, got {}",
1418                row.row_digest
1419            ),
1420        });
1421    }
1422    Ok(())
1423}
1424
1425const EXACT_MODEL_ID_PREFIX: &str = "\0car-exact-model-id:";
1426
1427/// Mark a typed request as an exact immutable-id pin. The marker is consumed
1428/// before routing and never reaches a backend or delegated runner.
1429pub fn pin_exact_model_id(req: &mut GenerateRequest, model_id: String) -> Result<(), String> {
1430    if req.model.is_some() {
1431        return Err("`model` and `model_id` are mutually exclusive".to_string());
1432    }
1433    if model_id.trim().is_empty() {
1434        return Err("`model_id` must be a non-empty immutable id".to_string());
1435    }
1436    req.model = Some(format!("{EXACT_MODEL_ID_PREFIX}{model_id}"));
1437    req.params.strict_model = true;
1438    Ok(())
1439}
1440
1441pub fn exact_pinned_model_id(req: &GenerateRequest) -> Option<&str> {
1442    req.model
1443        .as_deref()
1444        .and_then(|model| model.strip_prefix(EXACT_MODEL_ID_PREFIX))
1445}
1446
1447/// Decide the auto-enabled thinking budget for a turn (F1). Coding turns — the
1448/// caller's EXPLICIT `IntentHint{task:Code}` (not the coarse keyword classifier,
1449/// which flags any prose containing "fix"/"bug"/"let ") — get a higher budget
1450/// than a general reasoning-heavy (Complex) turn; both require the model to
1451/// advertise extended thinking. Returns `None` when thinking should not be
1452/// auto-enabled. The budget only selects the effort level via
1453/// `reasoning_effort_from_budget` (24000 -> "high", 8000 -> "medium"); the
1454/// adaptive API decides the actual depth.
1455fn auto_thinking_budget(
1456    is_code_intent: bool,
1457    is_complex: bool,
1458    supports_thinking: bool,
1459) -> Option<usize> {
1460    if !supports_thinking {
1461        return None;
1462    }
1463    if is_code_intent {
1464        Some(24_000)
1465    } else if is_complex {
1466        Some(8_000)
1467    } else {
1468        None
1469    }
1470}
1471
1472/// Whether the caller EXPLICITLY tagged this turn as a coding task
1473/// (`IntentHint{task:Code}`) — the signal F1 keys the coding thinking budget on.
1474///
1475/// Deliberately NOT the keyword classifier's `decision.task`: the classifier
1476/// flags any prose containing "fix"/"bug"/"let " as Code, which would
1477/// over-provision high-effort thinking on incidental words, and a model-pin
1478/// clobbers `decision.task` to Generate (losing a pinned coder). Keeping this a
1479/// named pure fn (rather than an inline expression at the call site) pins that
1480/// invariant against a regression that re-keys the gate onto `decision.task`.
1481fn is_explicit_code_intent(intent: Option<&intent::IntentHint>) -> bool {
1482    intent.and_then(|h| h.task) == Some(intent::TaskHint::Code)
1483}
1484
1485/// Whether to append an installed on-device model as the remote-only last
1486/// resort. It fires only when the chain has no local model AND the request is
1487/// not a hard pin: a `strict_model` caller (the coder's `--model`, an A/B arm)
1488/// must fail loudly on a remote outage rather than silently degrade to local.
1489fn should_append_local_last_resort(chain_has_local: bool, strict_model: bool) -> bool {
1490    !chain_has_local && !strict_model
1491}
1492
1493/// The per-turn output budget for a resolved model.
1494///
1495/// When the caller left `max_tokens` at the library default we widen it to the
1496/// model's advertised output cap. That is what stops a long-horizon remote turn
1497/// from truncating a tool_use argument mid-object.
1498///
1499/// A model decoded **in-process** is the exception, and not a small one: its
1500/// token budget is a wall-clock budget. `mlx/qwen3-8b:4bit` advertises a 131072
1501/// context and no explicit output cap, so `effective_max_output()` widens 4096
1502/// to 32768 — about 24 minutes of decode at that catalog entry's own 22.4
1503/// tok/s, for one turn, and the empty result that a budget exhausted inside an
1504/// unclosed `<think>` block produces then trips the thinking-recovery retry in
1505/// [`InferenceEngine::generate_tracked`], which spends it a second time. That is
1506/// the reported hang: `car do --local` sat at ~39% CPU for 57 minutes with no
1507/// output. Such a model therefore keeps whatever budget the caller asked for.
1508/// `ModelSource::CodexCli` also keeps it: the CLI has no exact output-cap
1509/// option, so widening its best-effort instruction to a 128K catalog ceiling
1510/// would turn an ordinary default into a request for an enormous answer.
1511///
1512/// The local-model test is [`ModelSchema::decodes_in_process`], NOT `is_local` — vLLM-MLX
1513/// is local in the "runs on this machine" sense but is an HTTP server we do not
1514/// decode for, and it is precisely the local model whose tool_use JSON the
1515/// widening protects. (car#851)
1516fn resolved_max_tokens(requested: usize, schema: &ModelSchema) -> usize {
1517    if requested != crate::tasks::generate::DEFAULT_MAX_TOKENS
1518        || schema.decodes_in_process()
1519        || schema.is_codex_cli()
1520    {
1521        return requested;
1522    }
1523    schema.effective_max_output()
1524}
1525
1526/// Wall-clock ceiling on ONE in-process decode pass, in seconds.
1527///
1528/// Deliberately generous: this is a runaway backstop, not a latency target. A
1529/// decode that reaches it has stopped being useful to its caller, and until it
1530/// existed the only bound on the loop was `max_tokens` — which on a large local
1531/// model is tens of minutes of silent CPU with no way to tell a slow model from
1532/// a wedged one. (car#851)
1533///
1534/// Scope: both loops that honor it (`drive_generation_with_timeout` and
1535/// `stream_local_mlx`) are MLX, i.e. Apple Silicon. The Candle in-process path
1536/// used on every other platform is NOT bounded by this — it only gets the
1537/// `max_tokens` rule in [`resolved_max_tokens`].
1538const DEFAULT_LOCAL_DECODE_TIMEOUT_SECS: u64 = 300;
1539
1540/// `stop_reason` for a pass cut short by the ceiling above.
1541///
1542/// Deliberately not the bare `"timeout"`: a remote model's `stop_reason` is the
1543/// provider's raw `finish_reason` passed through with no allowlist, so a bare
1544/// spelling could collide with an endpoint that happens to emit it and hand a
1545/// remote caller an error naming a local wall clock. (car#851)
1546pub const LOCAL_DECODE_TIMEOUT_STOP_REASON: &str = "local_decode_timeout";
1547
1548/// How often the decode loop reports progress. Time-based, not every-N-tokens:
1549/// the defect being fixed is *silence*, and a token-count interval still goes
1550/// quiet exactly when the model slows down. (car#851)
1551///
1552/// Both users — `drive_generation_with_timeout` and `stream_local_mlx` — are
1553/// MLX-only, so carry their cfg here too or this is dead code everywhere else
1554/// and `-D warnings` fails the build on Linux.
1555#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1556const LOCAL_DECODE_HEARTBEAT_SECS: u64 = 10;
1557
1558/// Parse `CAR_LOCAL_DECODE_TIMEOUT_SECS`. `0` disables the ceiling; anything
1559/// unparseable falls back to the default rather than silently disabling it.
1560fn parse_decode_timeout(raw: Option<&str>) -> Option<std::time::Duration> {
1561    let secs = match raw {
1562        Some(v) => v
1563            .trim()
1564            .parse::<u64>()
1565            .unwrap_or(DEFAULT_LOCAL_DECODE_TIMEOUT_SECS),
1566        None => DEFAULT_LOCAL_DECODE_TIMEOUT_SECS,
1567    };
1568    (secs > 0).then(|| std::time::Duration::from_secs(secs))
1569}
1570
1571/// Has this decode pass run past its wall-clock ceiling? `None` = no ceiling.
1572///
1573/// Shared by BOTH decode loops (`drive_generation_with_timeout` and
1574/// `stream_local_mlx`) so the comparison exists once. An inverted or
1575/// off-by-one comparison here is the difference between a bounded decode and
1576/// the car#851 hang, and the streaming loop cannot be unit-tested against a
1577/// real MLX backend — so the predicate is what gets tested.
1578///
1579/// Both callers are MLX-gated, so on every other target this has no caller.
1580/// It stays compiled (rather than carrying the loops' `cfg`) so its test still
1581/// runs on Linux CI — a regression net that only works on the author's Mac is
1582/// not a regression net.
1583#[cfg_attr(
1584    not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
1585    allow(dead_code)
1586)]
1587fn deadline_exceeded(elapsed: std::time::Duration, timeout: Option<std::time::Duration>) -> bool {
1588    timeout.is_some_and(|limit| elapsed >= limit)
1589}
1590
1591/// Is another progress heartbeat due? Shared by both decode loops for the same
1592/// reason as [`deadline_exceeded`] — and because forgetting to advance
1593/// `last_heartbeat` turns the heartbeat into a per-token flood.
1594///
1595/// Ungated for the same reason as [`deadline_exceeded`].
1596#[cfg_attr(
1597    not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
1598    allow(dead_code)
1599)]
1600fn heartbeat_due(
1601    elapsed: std::time::Duration,
1602    last: std::time::Duration,
1603    interval: std::time::Duration,
1604) -> bool {
1605    elapsed.saturating_sub(last) >= interval
1606}
1607
1608/// What [`InferenceEngine::generate_tracked`] should do with the pass it just
1609/// completed. Pulled out of the async fn so the decision — the highest-blast-
1610/// radius part of the car#851 change — is unit-testable without a live model.
1611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1612enum EmptyPassAction {
1613    /// Nothing usable, and reasoning is the plausible culprit: retry once with
1614    /// thinking suppressed (the pre-existing car-releases#60 recovery).
1615    RetryWithoutThinking,
1616    /// Nothing usable and the local wall-clock ceiling is why. Retrying spends
1617    /// the same ceiling a second time and cannot end differently — that is how
1618    /// car#851 turned a 24-minute turn into a 49-minute one — so fail loudly.
1619    FailDecodeCeiling,
1620    /// Usable output, or nothing usable for a reason neither branch owns.
1621    Accept,
1622}
1623
1624/// Classify a completed pass. Order matters: the ceiling check comes FIRST,
1625/// because a ceiling stop looks exactly like a thinking truncation from here
1626/// (empty text, no tool calls — the model was still inside `<think>` when the
1627/// clock ran out) and the recovery retry would happily double the wall time.
1628///
1629/// A pass that produced *any* text or tool call is always accepted, ceiling or
1630/// not: a partial answer is worth more to the caller than an error.
1631fn classify_empty_pass(
1632    recover: bool,
1633    stop_reason: Option<&str>,
1634    text: &str,
1635    tool_calls_empty: bool,
1636) -> EmptyPassAction {
1637    if !text.trim().is_empty() || !tool_calls_empty {
1638        return EmptyPassAction::Accept;
1639    }
1640    if stop_reason == Some(LOCAL_DECODE_TIMEOUT_STOP_REASON) {
1641        return EmptyPassAction::FailDecodeCeiling;
1642    }
1643    if recover {
1644        return EmptyPassAction::RetryWithoutThinking;
1645    }
1646    EmptyPassAction::Accept
1647}
1648
1649fn local_decode_timeout() -> Option<std::time::Duration> {
1650    parse_decode_timeout(
1651        std::env::var("CAR_LOCAL_DECODE_TIMEOUT_SECS")
1652            .ok()
1653            .as_deref(),
1654    )
1655}
1656
1657impl InferenceResult {
1658    /// Canonical immutable id of the model that served this turn.
1659    ///
1660    /// Older/scripted payloads may not carry the flattened v3 identity fields;
1661    /// retain `model_used` as their compatibility fallback.
1662    pub fn served_model_id(&self) -> &str {
1663        if self.model_identity.resolved_model_id.is_empty() {
1664            &self.model_used
1665        } else {
1666            &self.model_identity.resolved_model_id
1667        }
1668    }
1669
1670    /// Returns true if the model chose to call tools instead of generating text.
1671    pub fn has_tool_calls(&self) -> bool {
1672        !self.tool_calls.is_empty()
1673    }
1674
1675    /// Returns true when the response was cut short rather than finished.
1676    /// Matches every provider spelling of an output-token cap: OpenAI chat
1677    /// `"length"`, OpenAI Responses `"max_output_tokens"`, Anthropic
1678    /// `"max_tokens"`, Google `"MAX_TOKENS"`, and the local MLX/Candle
1679    /// `"length"` — plus the local wall-clock ceiling
1680    /// (`"local_decode_timeout"`, car#851), which produces the same partial
1681    /// text for a different reason and would otherwise read as a complete
1682    /// answer. A truncated response often carries a half-written tool_use
1683    /// argument the validator will reject, so callers (e.g. car-cli run_task)
1684    /// should detect this and ask the model to retry in smaller chunks rather
1685    /// than re-emitting the oversized call.
1686    pub fn was_truncated(&self) -> bool {
1687        matches!(
1688            self.stop_reason.as_deref(),
1689            Some(
1690                "length"
1691                    | "max_tokens"
1692                    | "max_output_tokens"
1693                    | "MAX_TOKENS"
1694                    | crate::LOCAL_DECODE_TIMEOUT_STOP_REASON
1695            )
1696        )
1697    }
1698
1699    /// Append this result to a caller-owned multi-turn history.
1700    ///
1701    /// Responses continuity items belong immediately before the assistant
1702    /// message they accompanied in the provider's output sequence. Keeping the
1703    /// ordering here centralized prevents CAR's agent, coder, bench, and CLI
1704    /// loops from independently dropping or misordering opaque reasoning state.
1705    /// Personal Chat Completions and non-Responses providers leave
1706    /// `provider_output_items` empty, so their history shape is unchanged.
1707    pub fn append_assistant_history(
1708        &self,
1709        messages: &mut Vec<crate::tasks::generate::Message>,
1710        tool_calls: Vec<crate::tasks::generate::ToolCall>,
1711    ) {
1712        if !self.provider_output_items.is_empty() {
1713            messages.push(crate::tasks::generate::Message::ProviderOutputItems {
1714                protocol: crate::protocol::OPENAI_RESPONSES_PROTOCOL.to_string(),
1715                items: self.provider_output_items.clone(),
1716            });
1717        }
1718        messages.push(crate::tasks::generate::Message::Assistant {
1719            content: self.text.clone(),
1720            tool_calls,
1721            thinking: self.thinking.clone(),
1722            model_id: Some(self.served_model_id().to_string()),
1723            local_last_resort: self.local_last_resort,
1724        });
1725    }
1726}
1727
1728#[derive(Debug, Clone, Serialize)]
1729pub struct SpeechRuntimeHealth {
1730    pub root: PathBuf,
1731    pub installed: bool,
1732    pub python: PathBuf,
1733    pub stt_command: PathBuf,
1734    pub tts_command: PathBuf,
1735    pub configured_python: Option<String>,
1736    pub detected_python: Option<String>,
1737}
1738
1739#[derive(Debug, Clone, Serialize)]
1740pub struct SpeechModelHealth {
1741    pub id: String,
1742    pub name: String,
1743    pub provider: String,
1744    pub capability: ModelCapability,
1745    pub is_local: bool,
1746    pub available: bool,
1747    pub cached: bool,
1748    pub selected_by_default: bool,
1749    pub source: String,
1750}
1751
1752#[derive(Debug, Clone, Serialize)]
1753pub struct SpeechHealthReport {
1754    pub runtime: SpeechRuntimeHealth,
1755    pub local_models: Vec<SpeechModelHealth>,
1756    pub remote_models: Vec<SpeechModelHealth>,
1757    pub elevenlabs_configured: bool,
1758    pub prefer_local: bool,
1759    pub allow_remote_fallback: bool,
1760    pub preferred_local_stt: Option<String>,
1761    pub preferred_local_tts: Option<String>,
1762    pub preferred_remote_stt: Option<String>,
1763    pub preferred_remote_tts: Option<String>,
1764    pub local_stt_default: Option<String>,
1765    pub local_tts_default: Option<String>,
1766    pub remote_stt_default: Option<String>,
1767    pub remote_tts_default: Option<String>,
1768}
1769
1770#[derive(Debug, Clone, Serialize)]
1771pub struct ModelDefaultHealth {
1772    pub capability: ModelCapability,
1773    pub configured_model: String,
1774    pub available: bool,
1775    pub is_local: bool,
1776    pub provider: Option<String>,
1777}
1778
1779#[derive(Debug, Clone, Serialize)]
1780pub struct ModelProviderHealth {
1781    pub provider: String,
1782    pub configured: bool,
1783    pub local_models: usize,
1784    pub remote_models: usize,
1785    pub available_models: usize,
1786    pub capabilities: Vec<ModelCapability>,
1787}
1788
1789#[derive(Debug, Clone, Serialize)]
1790pub struct ModelCapabilityHealth {
1791    pub capability: ModelCapability,
1792    pub total_models: usize,
1793    pub available_models: usize,
1794    pub local_available_models: usize,
1795    pub remote_available_models: usize,
1796}
1797
1798#[derive(Debug, Clone, Serialize)]
1799pub struct RoutingScenarioHealth {
1800    pub name: String,
1801    pub workload: RoutingWorkload,
1802    pub task_family: String,
1803    pub has_tools: bool,
1804    pub has_vision: bool,
1805    pub prefer_local: bool,
1806    pub quality_first_cold_start: bool,
1807    pub bootstrap_min_task_observations: u64,
1808    pub bootstrap_quality_floor: f64,
1809    pub model_id: String,
1810    pub model_name: String,
1811    pub reason: String,
1812    pub strategy: RoutingStrategy,
1813}
1814
1815#[derive(Debug, Clone, Serialize)]
1816pub struct ModelBenchmarkPriorHealth {
1817    pub model_id: String,
1818    pub model_name: Option<String>,
1819    pub overall_score: f64,
1820    pub overall_latency_ms: Option<f64>,
1821    pub task_scores: std::collections::HashMap<String, f64>,
1822    pub task_latency_ms: std::collections::HashMap<String, f64>,
1823    pub source_path: PathBuf,
1824}
1825
1826#[derive(Debug, Clone, Serialize)]
1827pub struct ModelHealthReport {
1828    pub total_models: usize,
1829    pub available_models: usize,
1830    pub local_models: usize,
1831    pub remote_models: usize,
1832    pub defaults: Vec<ModelDefaultHealth>,
1833    pub providers: Vec<ModelProviderHealth>,
1834    pub capabilities: Vec<ModelCapabilityHealth>,
1835    pub routing_prefer_local: bool,
1836    pub routing_quality_first_cold_start: bool,
1837    pub routing_min_observations: u64,
1838    pub routing_bootstrap_min_task_observations: u64,
1839    pub routing_bootstrap_quality_floor: f64,
1840    pub routing_quality_weight: f64,
1841    pub routing_latency_weight: f64,
1842    pub routing_cost_weight: f64,
1843    pub routing_scenarios: Vec<RoutingScenarioHealth>,
1844    pub benchmark_priors: Vec<ModelBenchmarkPriorHealth>,
1845    pub speech: SpeechHealthReport,
1846}
1847
1848#[derive(Debug, Clone, Serialize)]
1849pub struct SpeechInstallReport {
1850    pub name: String,
1851    pub hf_repo: String,
1852    pub snapshot_path: PathBuf,
1853    pub files_downloaded: usize,
1854}
1855
1856#[derive(Debug, Clone, Serialize)]
1857pub struct SpeechSmokePathReport {
1858    pub path: String,
1859    pub tts_model: String,
1860    pub stt_model: String,
1861    pub audio_path: PathBuf,
1862    pub transcript: String,
1863}
1864
1865#[derive(Debug, Clone, Serialize, Default)]
1866pub struct SpeechSmokeReport {
1867    pub local: Option<SpeechSmokePathReport>,
1868    pub remote: Option<SpeechSmokePathReport>,
1869    pub skipped: Vec<String>,
1870}
1871
1872#[derive(Debug, Clone, Serialize, Default)]
1873pub struct SpeechPolicy {
1874    pub prefer_local: bool,
1875    pub allow_remote_fallback: bool,
1876    pub preferred_local_stt: Option<String>,
1877    pub preferred_local_tts: Option<String>,
1878    pub preferred_remote_stt: Option<String>,
1879    pub preferred_remote_tts: Option<String>,
1880}
1881
1882/// Pre-render a request for an in-process (MLX/candle) backend. Those backends
1883/// have no native `messages`/`tools` API — they complete a single prompt string
1884/// — so when a request carries multi-turn `messages` and/or `tools`, fold them
1885/// into a Qwen3 chat-format `prompt` (signatures in a `<tools>` block; the model
1886/// emits `<tool_call>` which `parse_tool_calls` recovers) and clear the
1887/// structured fields so the downstream `apply_chat_template` pass-through uses
1888/// the rendered text. A request with neither is returned unchanged (the local
1889/// generate path stays byte-for-byte identical for plain text completion).
1890fn render_for_local_backend(mut req: GenerateRequest) -> GenerateRequest {
1891    let has_msgs = req.messages.as_ref().is_some_and(|m| !m.is_empty());
1892    let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
1893    if has_msgs || has_tools {
1894        req.prompt = tasks::generate::render_chat_prompt(&req);
1895        req.messages = None;
1896        req.tools = None;
1897    }
1898    req
1899}
1900
1901/// The main inference engine. Thread-safe, lazily loads models.
1902///
1903/// Now includes the unified registry, adaptive router, and outcome tracker
1904/// for schema-driven model selection with learned performance profiles.
1905pub struct InferenceEngine {
1906    pub config: InferenceConfig,
1907    /// Unified model registry (local + remote).
1908    pub unified_registry: UnifiedRegistry,
1909    /// Adaptive router with three-phase selection.
1910    pub adaptive_router: AdaptiveRouter,
1911    /// Outcome tracker for learning from results.
1912    pub outcome_tracker: Arc<RwLock<OutcomeTracker>>,
1913    /// Last time `auto_save_outcomes` flushed the tracker (debounce gate).
1914    /// `None` until the first flush. Paired with the tracker's dirty flag
1915    /// so we persist at most once per `OUTCOME_FLUSH_INTERVAL` and only
1916    /// when a profile actually changed — instead of rewriting the whole
1917    /// file after every inference call.
1918    last_outcome_flush: Arc<std::sync::Mutex<Option<Instant>>>,
1919    /// Serializes all mutations of the outcome-ledger file so a concurrent
1920    /// append (from a per-call flush) and a prune (read+rename) can't
1921    /// interleave and drop a receipt — the ledger's whole value is that no
1922    /// receipt is silently lost.
1923    ledger_io_lock: Arc<tokio::sync::Mutex<()>>,
1924    /// Optional spend limits (I4). When `per_request_usd` is set, the
1925    /// streaming path arms a [`routing_ext::MidStreamSpendGuard`] so a
1926    /// runaway long output is cancelled mid-stream instead of billed to
1927    /// completion. Rust-embedder API (no FFI surface by design — see the
1928    /// I4 handoff note); set via [`InferenceEngine::set_spend_limits`].
1929    spend_limits: Arc<std::sync::RwLock<Option<SpendLimits>>>,
1930    /// In-memory cache of lane defaults (Phase D1), so the hot routing
1931    /// path consults the user's pinned models without a disk read per
1932    /// inference. Loaded at construction; kept in sync by
1933    /// `set_lane_default`/`clear_lane_default` (which also persist).
1934    lane_defaults_cache: Arc<std::sync::RwLock<crate::lane_defaults::LaneDefaults>>,
1935    /// Serializes lane-mutating concierge operations (apply / rollback /
1936    /// canary revert) so the action-ledger read-modify-write is atomic —
1937    /// the canary watcher and a user `apply` can't interleave and revert a
1938    /// switch the user just made. The model download in `apply` stays
1939    /// OUTSIDE this lock (no blocking the canary tick on a multi-GB pull).
1940    concierge_action_lock: Arc<tokio::sync::Mutex<()>>,
1941    /// Monotonic counter for concierge action `seq` (the canary's anchor
1942    /// identity). Initialized past the max seq already in the ledger so it
1943    /// stays monotonic across restarts.
1944    concierge_action_seq: Arc<std::sync::atomic::AtomicU64>,
1945    /// HTTP client for remote API models.
1946    remote_backend: RemoteBackend,
1947    /// Durable ownership/tombstone records plus machine-shared activity locks
1948    /// for local model artifacts.
1949    model_management: model_management::ModelManagementStore,
1950    /// Shared admission service used by every CAR-owned local allocation.
1951    local_admission: Arc<resource_policy::LocalAdmissionCoordinator>,
1952    /// Keeps the state-root-scoped runtime composition alive. Engines pointed
1953    /// at the same CAR state root share every resident backend/cache/process;
1954    /// this prevents a second engine from loading peer-discounted duplicate
1955    /// weights or clearing another engine's residency accounting.
1956    _runtime_scope: Arc<ScopedInferenceRuntime>,
1957    resource_policy_generation: Arc<std::sync::atomic::AtomicU64>,
1958    /// Startup load source/warning paired with the active admission policy.
1959    /// The policy itself is refreshed from `local_admission` by the accessor,
1960    /// so independently constructed engines sharing one coordinator agree.
1961    resource_policy_evidence: Arc<std::sync::RwLock<resource_policy::ResourcePolicyLoadEvidence>>,
1962    /// Mutable cache ceiling paired with `local_admission` so a policy change
1963    /// affects both future reservations and retained idle weights.
1964    model_budget: Arc<backend_cache::SharedModelBudget>,
1965    /// Native MLX text-gen / embedding backends keyed by model id.
1966    /// Same cache shape as `flux_cache` / `ltx_cache` / `kokoro_cache`:
1967    /// per-entry `Arc<Mutex<MlxBackend>>` so concurrent calls for the
1968    /// same model serialize, while calls for different models proceed
1969    /// in parallel. Also bounded by `CAR_INFERENCE_MODEL_CACHE_MB`.
1970    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1971    mlx_backends: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
1972    /// Polymorphic cache of NEW-architecture in-process MLX backends (Gemma 4,
1973    /// …) dispatched via `backend::local::local_backend_for` and driven by the
1974    /// shared `drive_generation` loop. Qwen3 keeps the dedicated `mlx_backends`
1975    /// cache above (which also backs streaming / tokenize / embeddings); each
1976    /// model lives in exactly one cache by architecture, so there is no
1977    /// double-load.
1978    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1979    local_backends:
1980        Arc<backend_cache::BackendCache<Box<dyn backend::local::LocalInferenceBackend>>>,
1981    /// LRU-evicting, mutex-serialized cache of loaded Flux image backends.
1982    /// Avoids reloading the 4–5 GB model on every generate_image call,
1983    /// and serializes concurrent calls onto the same backend (MLX ops
1984    /// are not `Sync`).
1985    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1986    flux_cache: Arc<backend_cache::BackendCache<backend::mlx_flux::FluxBackend>>,
1987    /// Same for LTX video (~9 GB: transformer + Gemma 3 12B + VAE + vocoder).
1988    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1989    ltx_cache: Arc<backend_cache::BackendCache<backend::mlx_ltx::LtxBackend>>,
1990    /// Same for Kokoro TTS (~160 MB). Small but reloading per-utterance
1991    /// added ~1 s of latency to every `synth` call.
1992    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1993    kokoro_cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
1994    // Legacy fields kept for backward compatibility
1995    pub registry: models::ModelRegistry,
1996    pub router: ModelRouter,
1997    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1998    backend: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
1999    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2000    embedding_backend: Arc<RwLock<Option<EmbeddingBackend>>>,
2001    speech_runtime: Arc<Mutex<Option<SpeechRuntime>>>,
2002    speech_policy: SpeechPolicy,
2003    /// On-demand supervised `vllm-mlx` servers for `vllm-mlx/*` models. Lazy-
2004    /// started on dispatch, idle-evicted alongside the in-process backends, so a
2005    /// server-backed model is indistinguishable from an in-process one.
2006    vllm_pool: Arc<vllm_pool::VllmServerPool>,
2007}
2008
2009enum SpeechCandidateAdmission {
2010    Proceed(Option<resource_policy::LocalLoadReservation>),
2011    SkipBlocked(InferenceError),
2012    FailBlocked(InferenceError),
2013}
2014
2015struct ScopedInferenceRuntime {
2016    model_budget: Arc<backend_cache::SharedModelBudget>,
2017    resource_policy_generation: Arc<std::sync::atomic::AtomicU64>,
2018    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2019    mlx_backends: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
2020    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2021    local_backends:
2022        Arc<backend_cache::BackendCache<Box<dyn backend::local::LocalInferenceBackend>>>,
2023    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2024    flux_cache: Arc<backend_cache::BackendCache<backend::mlx_flux::FluxBackend>>,
2025    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2026    ltx_cache: Arc<backend_cache::BackendCache<backend::mlx_ltx::LtxBackend>>,
2027    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2028    kokoro_cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2029    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2030    backend: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
2031    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2032    embedding_backend: Arc<RwLock<Option<EmbeddingBackend>>>,
2033    speech_runtime: Arc<Mutex<Option<SpeechRuntime>>>,
2034    vllm_pool: Arc<vllm_pool::VllmServerPool>,
2035    #[cfg(test)]
2036    load_probe: Arc<backend_cache::BackendCache<()>>,
2037}
2038
2039fn scoped_inference_runtime_registry() -> &'static std::sync::Mutex<
2040    std::collections::HashMap<PathBuf, std::sync::Weak<ScopedInferenceRuntime>>,
2041> {
2042    static REGISTRY: std::sync::OnceLock<
2043        std::sync::Mutex<
2044            std::collections::HashMap<PathBuf, std::sync::Weak<ScopedInferenceRuntime>>,
2045        >,
2046    > = std::sync::OnceLock::new();
2047    REGISTRY.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
2048}
2049
2050fn configured_cache_budget_mb(default_mb: u64) -> u64 {
2051    std::env::var("CAR_INFERENCE_MODEL_CACHE_MB")
2052        .ok()
2053        .and_then(|value| value.parse::<u64>().ok())
2054        .unwrap_or(default_mb)
2055}
2056
2057fn scoped_inference_runtime(
2058    state_root: &Path,
2059    configured_ceiling_mb: u64,
2060    admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2061) -> Arc<ScopedInferenceRuntime> {
2062    let state_root = resource_policy::normalized_state_root_key(state_root);
2063    let mut registry = scoped_inference_runtime_registry()
2064        .lock()
2065        .unwrap_or_else(std::sync::PoisonError::into_inner);
2066    registry.retain(|_, runtime| runtime.strong_count() > 0);
2067    if let Some(runtime) = registry.get(&state_root).and_then(std::sync::Weak::upgrade) {
2068        runtime.model_budget.set_budget_bytes(
2069            configured_cache_budget_mb(configured_ceiling_mb).saturating_mul(1024 * 1024),
2070        );
2071        return runtime;
2072    }
2073
2074    let model_budget = backend_cache::SharedModelBudget::from_env_or(configured_ceiling_mb);
2075    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2076    let cache_idle = backend_cache::idle_ttl_from_env();
2077    let runtime = Arc::new(ScopedInferenceRuntime {
2078        model_budget: model_budget.clone(),
2079        resource_policy_generation: Arc::new(std::sync::atomic::AtomicU64::new(1)),
2080        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2081        mlx_backends: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2082            model_budget.clone(),
2083            cache_idle,
2084            Some(admission.clone()),
2085        )),
2086        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2087        local_backends: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2088            model_budget.clone(),
2089            cache_idle,
2090            Some(admission.clone()),
2091        )),
2092        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2093        flux_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2094            model_budget.clone(),
2095            cache_idle,
2096            Some(admission.clone()),
2097        )),
2098        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2099        ltx_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2100            model_budget.clone(),
2101            cache_idle,
2102            Some(admission.clone()),
2103        )),
2104        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2105        kokoro_cache: Arc::new(backend_cache::BackendCache::from_shared_with_admission(
2106            model_budget,
2107            cache_idle,
2108            Some(admission.clone()),
2109        )),
2110        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2111        backend: Arc::new(RwLock::new(std::collections::HashMap::new())),
2112        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2113        embedding_backend: Arc::new(RwLock::new(None)),
2114        speech_runtime: Arc::new(Mutex::new(None)),
2115        vllm_pool: Arc::new(vllm_pool::VllmServerPool::with_admission(
2116            std::time::Duration::from_secs(
2117                std::env::var("CAR_VLLM_IDLE_SECS")
2118                    .ok()
2119                    .and_then(|value| value.parse().ok())
2120                    .unwrap_or(300),
2121            ),
2122            admission,
2123        )),
2124        #[cfg(test)]
2125        load_probe: Arc::new(backend_cache::BackendCache::new(1024)),
2126    });
2127    registry.insert(state_root, Arc::downgrade(&runtime));
2128    runtime
2129}
2130
2131/// A root-scoped Kokoro cache lease shared by the inference engine and
2132/// car-voice. The runtime is held only for as long as an actual voice owner
2133/// exists; merely importing car-voice cannot pin the default CAR runtime.
2134#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2135pub struct ScopedKokoroBackendCache {
2136    cache: Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>>,
2137    _runtime: Arc<ScopedInferenceRuntime>,
2138    admission: Arc<resource_policy::LocalAdmissionCoordinator>,
2139}
2140
2141#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2142impl ScopedKokoroBackendCache {
2143    pub fn cache(&self) -> &Arc<backend_cache::BackendCache<backend::mlx_kokoro::KokoroBackend>> {
2144        &self.cache
2145    }
2146
2147    pub fn admission(&self) -> &Arc<resource_policy::LocalAdmissionCoordinator> {
2148        &self.admission
2149    }
2150}
2151
2152#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2153pub fn scoped_kokoro_backend_cache(state_root: &Path) -> ScopedKokoroBackendCache {
2154    let state_root = resource_policy::normalized_state_root_key(state_root);
2155    let policy = resource_policy::FileResourcePolicyRepository::new(state_root.clone())
2156        .load()
2157        .unwrap_or_else(|_| resource_policy::ResourcePolicy::everyday());
2158    let hardware = HardwareInfo::detect();
2159    let admission = resource_policy::scoped_local_admission(&state_root, policy, hardware.clone());
2160    let ceiling_mb = admission
2161        .policy()
2162        .effective_budget(hardware.total_ram_mb)
2163        .configured_model_ceiling_mb;
2164    let runtime = scoped_inference_runtime(&state_root, ceiling_mb, admission.clone());
2165    ScopedKokoroBackendCache {
2166        cache: runtime.kokoro_cache.clone(),
2167        _runtime: runtime,
2168        admission,
2169    }
2170}
2171
2172impl InferenceEngine {
2173    fn requires_local_admission(schema: &ModelSchema) -> bool {
2174        schema.is_car_managed_vllm_mlx() || Self::supports_worker_offload(schema)
2175    }
2176
2177    /// Managed vLLM owns a wider startup transaction than ordinary local
2178    /// loaders: its reservation must be created only after the per-model
2179    /// dispatch gate is held. Reserving in the generic outer path lets a
2180    /// concurrent request observe the first startup's provisional process
2181    /// charge and fail before it can join the singleflight.
2182    fn reserve_in_outer_dispatch(schema: &ModelSchema) -> bool {
2183        Self::requires_local_admission(schema) && !schema.is_car_managed_vllm_mlx()
2184    }
2185
2186    fn supports_worker_offload(schema: &ModelSchema) -> bool {
2187        matches!(
2188            schema.source,
2189            ModelSource::Local { .. } | ModelSource::Mlx { .. }
2190        )
2191    }
2192
2193    /// Actual post-dispatch retention observed by worker/process owners.
2194    /// Success alone is insufficient: zero-cache loads remain transient.
2195    pub fn local_model_retention(&self, model_id: &str) -> backend_cache::BackendRetention {
2196        if self.local_admission.is_resident(model_id) {
2197            backend_cache::BackendRetention::Resident
2198        } else {
2199            backend_cache::BackendRetention::Transient
2200        }
2201    }
2202    /// Evaluate one local model without downloading or loading it.
2203    pub fn local_model_preflight(
2204        &self,
2205        model_id: &str,
2206        context_tokens: usize,
2207    ) -> Result<resource_policy::LocalLoadPreflight, InferenceError> {
2208        let schema = self
2209            .unified_registry
2210            .get(model_id)
2211            .or_else(|| self.unified_registry.find_by_name(model_id))
2212            .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
2213        self.ensure_model_enabled(&schema.id)?;
2214        Ok(self.local_admission.preflight(schema, context_tokens))
2215    }
2216
2217    /// The policy currently enforced by local-model admission, plus the load
2218    /// source/warning captured when this engine initialized it.
2219    ///
2220    /// Read-side model surfaces use this accessor rather than reopening the
2221    /// policy file, so a policy applied to the running engine is one atomic
2222    /// source of truth for preflight, fit annotations, and recommendations.
2223    pub fn active_local_resource_policy(&self) -> resource_policy::ResourcePolicyLoadEvidence {
2224        let mut evidence = self
2225            .resource_policy_evidence
2226            .read()
2227            .unwrap_or_else(|poisoned| poisoned.into_inner())
2228            .clone();
2229        evidence.policy = self.local_admission.policy();
2230        evidence
2231    }
2232
2233    /// Update the in-memory admission/cache ceiling after persistence succeeds.
2234    /// Idle entries are reclaimed by each cache's next sweep/access; active
2235    /// inference is never killed by a policy decrease.
2236    pub fn apply_local_resource_policy(&self, policy: resource_policy::ResourcePolicy) {
2237        let ceiling_mb = policy
2238            .effective_budget(HardwareInfo::detect().total_ram_mb)
2239            .configured_model_ceiling_mb;
2240        self.local_admission.set_policy(policy.clone());
2241        *self
2242            .resource_policy_evidence
2243            .write()
2244            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
2245            resource_policy::ResourcePolicyLoadEvidence {
2246                policy,
2247                source: resource_policy::ResourcePolicyLoadSource::Loaded,
2248                warning: None,
2249            };
2250        let cache_ceiling_mb = configured_cache_budget_mb(ceiling_mb);
2251        self.model_budget
2252            .set_budget_bytes(cache_ceiling_mb.saturating_mul(1024 * 1024));
2253        let generation = self
2254            .resource_policy_generation
2255            .fetch_add(1, std::sync::atomic::Ordering::AcqRel)
2256            .saturating_add(1);
2257        if let Some(offload) = crate::offload::current_local_offload() {
2258            offload.refresh_resource_policy(generation);
2259        }
2260        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2261        {
2262            self.mlx_backends.enforce_budget();
2263            self.local_backends.enforce_budget();
2264            self.flux_cache.enforce_budget();
2265            self.ltx_cache.enforce_budget();
2266            self.kokoro_cache.enforce_budget();
2267        }
2268    }
2269
2270    pub fn begin_local_model_maintenance(
2271        &self,
2272        model_id: &str,
2273    ) -> Result<resource_policy::LocalModelMaintenanceGuard, resource_policy::ModelMaintenanceError>
2274    {
2275        self.local_admission.begin_model_maintenance(model_id)
2276    }
2277
2278    pub async fn prepare_local_model_removal(
2279        &self,
2280        model_id: &str,
2281    ) -> Result<resource_policy::LocalModelMaintenanceGuard, resource_policy::ModelMaintenanceError>
2282    {
2283        let maintenance = self.local_admission.begin_model_maintenance(model_id)?;
2284        if let Some(offload) = crate::offload::current_local_offload() {
2285            if offload
2286                .resident_models()
2287                .await
2288                .iter()
2289                .any(|resident| resident == model_id)
2290            {
2291                let acknowledged = offload.release_model(model_id).await.map_err(|error| {
2292                    resource_policy::ModelMaintenanceError::ReleaseFailed(error.to_string())
2293                })?;
2294                if !acknowledged
2295                    || offload
2296                        .resident_models()
2297                        .await
2298                        .iter()
2299                        .any(|resident| resident == model_id)
2300                {
2301                    return Err(
2302                        resource_policy::ModelMaintenanceError::WorkerReleaseUnacknowledged(
2303                            model_id.to_string(),
2304                        ),
2305                    );
2306                }
2307            }
2308        }
2309        if self
2310            .vllm_pool
2311            .release_model_if_present(model_id)
2312            .await
2313            .is_err()
2314        {
2315            return Err(
2316                resource_policy::ModelMaintenanceError::ProcessReleaseUnacknowledged(
2317                    model_id.to_string(),
2318                ),
2319            );
2320        }
2321        if !self.evict_local_model_if_idle(model_id) {
2322            return Err(resource_policy::ModelMaintenanceError::CacheReleaseBlocked(
2323                model_id.to_string(),
2324            ));
2325        }
2326        let allocation_ids = self.local_admission.resident_allocation_ids(model_id);
2327        if !allocation_ids.is_empty() {
2328            return Err(resource_policy::ModelMaintenanceError::ResidualResidency {
2329                model_id: model_id.to_string(),
2330                allocation_ids,
2331            });
2332        }
2333        Ok(maintenance)
2334    }
2335
2336    /// Targeted cache eviction for safe model removal. Callers must hold the
2337    /// per-model maintenance guard while invoking this and checking any
2338    /// worker/cross-process leases.
2339    pub fn evict_local_model_if_idle(&self, model_id: &str) -> bool {
2340        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2341        {
2342            let mut allocation_ids = self.local_admission.resident_allocation_ids(model_id);
2343            if !allocation_ids.iter().any(|id| id == model_id) {
2344                allocation_ids.push(model_id.to_string());
2345            }
2346            allocation_ids.into_iter().all(|allocation_id| {
2347                [
2348                    self.mlx_backends.evict_if_idle(&allocation_id),
2349                    self.local_backends.evict_if_idle(&allocation_id),
2350                    self.flux_cache.evict_if_idle(&allocation_id),
2351                    self.ltx_cache.evict_if_idle(&allocation_id),
2352                    self.kokoro_cache.evict_if_idle(&allocation_id),
2353                ]
2354                .into_iter()
2355                .all(|evicted| evicted)
2356            })
2357        }
2358        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2359        {
2360            let _ = model_id;
2361            let backend_released = self.backend.try_write().is_ok_and(|mut backends| {
2362                if backends.remove(model_id).is_some() {
2363                    self.local_admission.mark_evicted(model_id);
2364                }
2365                true
2366            });
2367            // The embedding slot is still single-purpose. Fail closed when it
2368            // is populated or actively locked rather than unloading an
2369            // unrelated embedding model.
2370            backend_released
2371                && self
2372                    .embedding_backend
2373                    .try_write()
2374                    .is_ok_and(|slot| slot.is_none())
2375        }
2376    }
2377
2378    fn reserve_local_request(
2379        &self,
2380        schema: &ModelSchema,
2381        context_tokens: usize,
2382    ) -> Result<resource_policy::LocalLoadReservation, InferenceError> {
2383        self.ensure_model_enabled(&schema.id)?;
2384        let activity_lease = self.model_management.acquire_lease(&schema.id)?;
2385        let mut reservation = self
2386            .local_admission
2387            .reserve(schema, context_tokens)
2388            .map_err(InferenceError::from)?;
2389        reservation.attach_activity_lease(activity_lease);
2390        Ok(reservation)
2391    }
2392
2393    /// Recheck an installed allocation used only for the duration of one
2394    /// subprocess call. This deliberately never publishes resident weights;
2395    /// dropping the reservation after the subprocess exits releases the full
2396    /// measured allocation.
2397    #[cfg(any(
2398        test,
2399        all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))
2400    ))]
2401    fn reconcile_transient_local_allocation(
2402        reservation: &mut resource_policy::LocalLoadReservation,
2403        measured_weights_bytes: u64,
2404    ) -> Result<(), InferenceError> {
2405        reservation
2406            .reconcile_measured_weights(measured_weights_bytes)
2407            .map(|_| ())
2408            .map_err(InferenceError::from)
2409    }
2410
2411    fn prepare_worker_admission(
2412        &self,
2413        schema: &ModelSchema,
2414        reservation: &mut resource_policy::LocalLoadReservation,
2415    ) -> Result<crate::offload::LocalWorkerAdmission, InferenceError> {
2416        let installed = self.config.models_dir.join(&schema.name);
2417        let installed_weights_bytes = backend_cache::estimate_model_size(&installed);
2418        // On a fresh machine the parent may dispatch before the worker has
2419        // downloaded the managed artifact. Zero is "not measured", not proof
2420        // that the model has no weights. Always re-run atomic admission after
2421        // binding the exact worker generation, using the larger of installed
2422        // evidence and the conservative catalog estimate; this prevents a new
2423        // worker from peer-discounting an older process still exiting.
2424        let measured_weights_bytes =
2425            installed_weights_bytes.max(reservation.reconciled_weights_bytes());
2426        reservation
2427            .reconcile_measured_weights(measured_weights_bytes)
2428            .map_err(InferenceError::from)?;
2429        Ok(crate::offload::LocalWorkerAdmission {
2430            policy: self.local_admission.policy(),
2431            policy_generation: self
2432                .resource_policy_generation
2433                .load(std::sync::atomic::Ordering::Acquire),
2434            state_root: resource_policy::normalized_state_root_key(&self.config.state_root),
2435            measured_weights_bytes,
2436        })
2437    }
2438
2439    async fn reconcile_worker_residency(
2440        offload: &dyn crate::offload::LocalGenerationOffload,
2441        expected_model_id: &str,
2442        residency: &crate::offload::LocalWorkerResidency,
2443        retention: backend_cache::BackendRetention,
2444        reservation: &mut resource_policy::LocalLoadReservation,
2445    ) -> Result<(), InferenceError> {
2446        if residency.model_id != expected_model_id {
2447            if retention == backend_cache::BackendRetention::Resident {
2448                let released = offload
2449                    .release_model(&residency.model_id)
2450                    .await
2451                    .unwrap_or(false);
2452                if !released {
2453                    let allocation_id = offload
2454                        .resident_allocation_id(&residency.model_id)
2455                        .unwrap_or_else(|| {
2456                            resource_policy::worker_process_allocation_id(&residency.model_id)
2457                        });
2458                    reservation.publish_resident_weights_as(
2459                        &allocation_id,
2460                        residency.measured_weights_bytes,
2461                    );
2462                }
2463            }
2464            return Err(InferenceError::InferenceFailed(format!(
2465                "local worker acknowledged model '{}' for requested '{}'",
2466                residency.model_id, expected_model_id
2467            )));
2468        }
2469        if retention == backend_cache::BackendRetention::Resident {
2470            let allocation_id = offload
2471                .resident_allocation_id(expected_model_id)
2472                .unwrap_or_else(|| {
2473                    resource_policy::worker_process_allocation_id(expected_model_id)
2474                });
2475            reservation
2476                .publish_resident_weights_as(&allocation_id, residency.measured_weights_bytes);
2477        }
2478        Ok(())
2479    }
2480
2481    fn admit_speech_candidate(
2482        &self,
2483        schema: &ModelSchema,
2484        explicit: bool,
2485    ) -> SpeechCandidateAdmission {
2486        if !schema.is_local()
2487            || matches!(
2488                schema.source,
2489                ModelSource::WindowsSpeech {} | ModelSource::AppleFoundationModels { .. }
2490            )
2491        {
2492            return SpeechCandidateAdmission::Proceed(None);
2493        }
2494        match self.reserve_local_request(schema, 0) {
2495            Ok(reservation) => SpeechCandidateAdmission::Proceed(Some(reservation)),
2496            Err(error) if explicit => SpeechCandidateAdmission::FailBlocked(error),
2497            Err(error) => SpeechCandidateAdmission::SkipBlocked(error),
2498        }
2499    }
2500
2501    fn hold_local_reservation_for_stream(
2502        mut source: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
2503        reservation: resource_policy::LocalLoadReservation,
2504    ) -> tokio::sync::mpsc::Receiver<stream::StreamEvent> {
2505        let (tx, rx) = tokio::sync::mpsc::channel(64);
2506        tokio::spawn(async move {
2507            let _reservation = reservation;
2508            while let Some(event) = source.recv().await {
2509                if tx.send(event).await.is_err() {
2510                    break;
2511                }
2512            }
2513        });
2514        rx
2515    }
2516
2517    fn hold_optional_reservation_for_stream(
2518        source: tokio::sync::mpsc::Receiver<stream::StreamEvent>,
2519        reservation: Option<resource_policy::LocalLoadReservation>,
2520    ) -> tokio::sync::mpsc::Receiver<stream::StreamEvent> {
2521        match reservation {
2522            Some(reservation) => Self::hold_local_reservation_for_stream(source, reservation),
2523            None => source,
2524        }
2525    }
2526
2527    /// Install (or clear) spend limits (I4). `per_request_usd` also arms
2528    /// the mid-stream guard on streaming calls: the stream is cancelled
2529    /// with a terminal `StopReason("spend_limit: ...")` the moment the
2530    /// estimated running cost (prompt + streamed output) crosses the
2531    /// budget.
2532    pub fn set_spend_limits(&self, limits: Option<SpendLimits>) {
2533        *self.spend_limits.write().unwrap() = limits;
2534    }
2535
2536    fn preferred_model_for_capability(&self, capability: ModelCapability) -> Option<&str> {
2537        match capability {
2538            ModelCapability::Generate => self.config.preferred_generation_model.as_deref(),
2539            ModelCapability::Embed => self.config.preferred_embedding_model.as_deref(),
2540            ModelCapability::Classify => self.config.preferred_classification_model.as_deref(),
2541            _ => None,
2542        }
2543    }
2544
2545    /// True when the request carries a NON-EMPTY tool catalog.
2546    /// `tools: Some(vec![])` is "no tools": it must not require the
2547    /// ToolUse capability in routing, and it must not push the
2548    /// FoundationModels dispatch onto the tool path (which would drop
2549    /// a `response_format` JsonSchema constraint for zero tools).
2550    fn request_has_tools(req: &GenerateRequest) -> bool {
2551        req.tools.as_ref().is_some_and(|t| !t.is_empty())
2552    }
2553
2554    fn request_needs_vision(req: &GenerateRequest) -> bool {
2555        req.images.as_ref().is_some_and(|images| !images.is_empty())
2556            || req.messages.as_ref().is_some_and(|messages| {
2557                messages
2558                    .iter()
2559                    .any(|msg| matches!(msg, Message::UserMultimodal { .. }))
2560            })
2561    }
2562
2563    /// True when any content block in the request carries video data.
2564    /// Backends without a video-tokenization path use this to reject
2565    /// the request up front with [`InferenceError::UnsupportedMode`]
2566    /// rather than silently dropping the content.
2567    #[allow(dead_code)] // conditionally compiled — used only on the FoundationModels (macOS) dispatch branch
2568    fn request_has_video(req: &GenerateRequest) -> bool {
2569        let images_have_video = req
2570            .images
2571            .as_ref()
2572            .is_some_and(|blocks| blocks.iter().any(ContentBlock::is_video));
2573        let messages_have_video = req.messages.as_ref().is_some_and(|messages| {
2574            messages.iter().any(|msg| match msg {
2575                Message::UserMultimodal { content } => content.iter().any(ContentBlock::is_video),
2576                _ => false,
2577            })
2578        });
2579        images_have_video || messages_have_video
2580    }
2581
2582    /// True when any content block in the request carries audio data.
2583    /// Same role as [`request_has_video`] but for the audio path
2584    /// (Gemma 4 small variants, Gemini).
2585    #[allow(dead_code)] // conditionally compiled — used only on the FoundationModels (macOS) dispatch branch
2586    fn request_has_audio(req: &GenerateRequest) -> bool {
2587        let images_have_audio = req
2588            .images
2589            .as_ref()
2590            .is_some_and(|blocks| blocks.iter().any(ContentBlock::is_audio));
2591        let messages_have_audio = req.messages.as_ref().is_some_and(|messages| {
2592            messages.iter().any(|msg| match msg {
2593                Message::UserMultimodal { content } => content.iter().any(ContentBlock::is_audio),
2594                _ => false,
2595            })
2596        });
2597        images_have_audio || messages_have_audio
2598    }
2599
2600    pub fn new(config: InferenceConfig) -> Self {
2601        let registry = models::ModelRegistry::new(config.models_dir.clone());
2602        let hw = HardwareInfo::detect();
2603        let policy_evidence =
2604            resource_policy::FileResourcePolicyRepository::new(config.state_root.clone())
2605                .load_with_evidence()
2606                .unwrap_or_else(|error| {
2607                    tracing::warn!(%error, "failed to read local model resource policy; using Everyday");
2608                    resource_policy::ResourcePolicyLoadEvidence {
2609                        policy: resource_policy::ResourcePolicy::everyday(),
2610                        source: resource_policy::ResourcePolicyLoadSource::CorruptDefault,
2611                        warning: Some(format!(
2612                            "The local-model resource policy could not be read ({error}); CAR used Everyday."
2613                        )),
2614                    }
2615                });
2616        let policy = policy_evidence.policy.clone();
2617        let effective_budget = policy.effective_budget(hw.total_ram_mb);
2618        let local_admission =
2619            resource_policy::scoped_local_admission(&config.state_root, policy, hw.clone());
2620        let runtime_scope = scoped_inference_runtime(
2621            &config.state_root,
2622            effective_budget.configured_model_ceiling_mb,
2623            local_admission.clone(),
2624        );
2625        let router = ModelRouter::new(hw.clone());
2626        let unified_registry = UnifiedRegistry::new_with_state_root(
2627            config.state_root.clone(),
2628            config.models_dir.clone(),
2629        );
2630        let adaptive_router = AdaptiveRouter::with_default_config(hw);
2631        let mut tracker = OutcomeTracker::new();
2632        // Load persisted profiles from previous sessions (#13)
2633        let profiles_path = config.state_models_dir().join("outcome_profiles.json");
2634        if let Ok(n) = tracker.load_from_file(&profiles_path) {
2635            if n > 0 {
2636                tracing::info!(loaded = n, "loaded persisted model profiles");
2637            }
2638        }
2639        let mut benchmark_models_loaded = 0usize;
2640        for path in benchmark_priors_paths(&config.state_models_dir()) {
2641            match routing_ext::load_benchmark_priors(&path) {
2642                Ok(priors) if !priors.is_empty() => {
2643                    benchmark_models_loaded += priors.len();
2644                    routing_ext::apply_benchmark_priors(&mut tracker, &priors);
2645                    tracing::info!(
2646                        path = %path.display(),
2647                        loaded = priors.len(),
2648                        "loaded benchmark quality priors"
2649                    );
2650                }
2651                Ok(_) => {}
2652                Err(error) => {
2653                    tracing::warn!(path = %path.display(), %error, "failed to load benchmark priors");
2654                }
2655            }
2656        }
2657        if benchmark_models_loaded > 0 {
2658            tracing::info!(
2659                loaded = benchmark_models_loaded,
2660                "applied benchmark priors to cold-start routing"
2661            );
2662        }
2663        let outcome_tracker = Arc::new(RwLock::new(tracker));
2664
2665        let remote_backend = RemoteBackend::new();
2666        let model_management = model_management::ModelManagementStore::new(
2667            config.state_root.clone(),
2668            config.models_dir.clone(),
2669        );
2670
2671        Self {
2672            config,
2673            unified_registry,
2674            adaptive_router,
2675            outcome_tracker,
2676            last_outcome_flush: Arc::new(std::sync::Mutex::new(None)),
2677            ledger_io_lock: Arc::new(tokio::sync::Mutex::new(())),
2678            spend_limits: Arc::new(std::sync::RwLock::new(None)),
2679            lane_defaults_cache: Arc::new(std::sync::RwLock::new(crate::lane_defaults::load_from(
2680                &crate::lane_defaults::default_path(),
2681            ))),
2682            concierge_action_lock: Arc::new(tokio::sync::Mutex::new(())),
2683            concierge_action_seq: Arc::new(std::sync::atomic::AtomicU64::new(
2684                crate::action_ledger::read_actions(&crate::action_ledger::default_path(), 0)
2685                    .iter()
2686                    .map(|a| a.seq)
2687                    .max()
2688                    .map(|m| m + 1)
2689                    .unwrap_or(1),
2690            )),
2691            remote_backend,
2692            model_management,
2693            local_admission: local_admission.clone(),
2694            _runtime_scope: runtime_scope.clone(),
2695            resource_policy_generation: runtime_scope.resource_policy_generation.clone(),
2696            resource_policy_evidence: Arc::new(std::sync::RwLock::new(policy_evidence)),
2697            model_budget: runtime_scope.model_budget.clone(),
2698            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2699            mlx_backends: runtime_scope.mlx_backends.clone(),
2700            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2701            local_backends: runtime_scope.local_backends.clone(),
2702            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2703            flux_cache: runtime_scope.flux_cache.clone(),
2704            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2705            ltx_cache: runtime_scope.ltx_cache.clone(),
2706            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2707            kokoro_cache: runtime_scope.kokoro_cache.clone(),
2708            registry,
2709            router,
2710            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2711            backend: runtime_scope.backend.clone(),
2712            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2713            embedding_backend: runtime_scope.embedding_backend.clone(),
2714            speech_runtime: runtime_scope.speech_runtime.clone(),
2715            speech_policy: SpeechPolicy {
2716                prefer_local: cfg!(all(
2717                    target_os = "macos",
2718                    target_arch = "aarch64",
2719                    not(car_skip_mlx)
2720                )),
2721                allow_remote_fallback: true,
2722                preferred_local_stt: None,
2723                preferred_local_tts: None,
2724                preferred_remote_stt: None,
2725                preferred_remote_tts: None,
2726            },
2727            vllm_pool: runtime_scope.vllm_pool.clone(),
2728        }
2729    }
2730
2731    /// Initialize key pool: register keys from all remote models and load persisted stats.
2732    /// Call this after construction (requires async).
2733    pub async fn init_key_pool(&self) {
2734        // Register keys from all remote models in the catalog
2735        for schema in self.unified_registry.list() {
2736            if schema.is_remote() {
2737                self.remote_backend.register_model_keys(schema).await;
2738            }
2739        }
2740
2741        // Load persisted key stats
2742        let stats_path = self.config.state_models_dir().join("key_pool_stats.json");
2743        if let Ok(n) = self.remote_backend.key_pool.load_stats(&stats_path).await {
2744            if n > 0 {
2745                tracing::info!(loaded = n, "loaded persisted key pool stats");
2746            }
2747        }
2748
2749        let total = self.remote_backend.key_pool.total_keys().await;
2750        if total > 0 {
2751            tracing::info!(keys = total, "key pool initialized");
2752        }
2753    }
2754
2755    /// Get or initialize the generative Candle backend, loading the specified model.
2756    /// Not used on Apple Silicon where all local inference goes through MLX.
2757    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2758    async fn ensure_backend(
2759        &self,
2760        schema: &ModelSchema,
2761        reservation: &mut resource_policy::LocalLoadReservation,
2762    ) -> Result<(), InferenceError> {
2763        let read = self.backend.read().await;
2764        if read.contains_key(&schema.id) {
2765            return Ok(());
2766        }
2767        drop(read);
2768
2769        let mut write = self.backend.write().await;
2770        if write.contains_key(&schema.id) {
2771            return Ok(());
2772        }
2773
2774        let model_path = self.registry.ensure_model(&schema.name).await?;
2775        let mut measured = backend_cache::estimate_model_size(&model_path);
2776        reservation
2777            .reconcile_measured_weights(measured)
2778            .map_err(InferenceError::from)?;
2779        let device = self.config.device.unwrap_or_else(Device::auto);
2780        // LOCAL_ADMISSION_BOUNDARY:adaptive-local-dispatch
2781        let backend = match CandleBackend::load(&model_path, device) {
2782            Ok(b) => b,
2783            Err(load_err) => {
2784                // A load failure with a provably-corrupt cache (truncated/pruned
2785                // weights from the shared HF store) self-heals: purge the bad
2786                // files, re-pull, retry once. An intact cache surfaces the error.
2787                if crate::download::purge_corrupt_cache_files(&model_path) == 0 {
2788                    return Err(load_err);
2789                }
2790                tracing::warn!(
2791                    model = %schema.id,
2792                    error = %load_err,
2793                    "candle backend load failed; purged corrupt cache files and re-pulling once"
2794                );
2795                let model_path = self.registry.ensure_model(&schema.name).await?;
2796                measured = backend_cache::estimate_model_size(&model_path);
2797                reservation
2798                    .reconcile_measured_weights(measured)
2799                    .map_err(InferenceError::from)?;
2800                CandleBackend::load(&model_path, device)?
2801            }
2802        };
2803        write.insert(schema.id.clone(), backend);
2804        reservation.publish_resident_weights(measured);
2805        Ok(())
2806    }
2807
2808    /// Get or initialize the embedding backend.
2809    /// On Apple Silicon, uses the MLX backend instead of Candle.
2810    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2811    async fn ensure_embedding_backend(
2812        &self,
2813        reservation: &mut resource_policy::LocalLoadReservation,
2814    ) -> Result<(), InferenceError> {
2815        let read = self.embedding_backend.read().await;
2816        if read.is_some() {
2817            return Ok(());
2818        }
2819        drop(read);
2820
2821        let mut write = self.embedding_backend.write().await;
2822        if write.is_some() {
2823            return Ok(());
2824        }
2825
2826        let embedding_model = self
2827            .preferred_model_for_capability(ModelCapability::Embed)
2828            .unwrap_or(&self.config.embedding_model);
2829        let model_path = self.registry.ensure_model(embedding_model).await?;
2830        let mut measured = backend_cache::estimate_model_size(&model_path);
2831        reservation
2832            .reconcile_measured_weights(measured)
2833            .map_err(InferenceError::from)?;
2834        let device = self.config.device.unwrap_or_else(Device::auto);
2835        // LOCAL_ADMISSION_BOUNDARY:embedding-dispatch
2836        let backend = match EmbeddingBackend::load(&model_path, device) {
2837            Ok(b) => b,
2838            Err(load_err) => {
2839                if crate::download::purge_corrupt_cache_files(&model_path) == 0 {
2840                    return Err(load_err);
2841                }
2842                tracing::warn!(
2843                    model = embedding_model,
2844                    error = %load_err,
2845                    "embedding backend load failed; purged corrupt cache files and re-pulling once"
2846                );
2847                let model_path = self.registry.ensure_model(embedding_model).await?;
2848                measured = backend_cache::estimate_model_size(&model_path);
2849                reservation
2850                    .reconcile_measured_weights(measured)
2851                    .map_err(InferenceError::from)?;
2852                EmbeddingBackend::load(&model_path, device)?
2853            }
2854        };
2855        *write = Some(backend);
2856        reservation.publish_resident_weights(measured);
2857        Ok(())
2858    }
2859
2860    /// On Apple Silicon, ensure the MLX embedding model is loaded.
2861    /// Returns the schema ID of the embedding model for keying into mlx_backends.
2862    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2863    async fn ensure_mlx_embedding_backend(&self) -> Result<String, InferenceError> {
2864        let embedding_model_name = self
2865            .preferred_model_for_capability(ModelCapability::Embed)
2866            .unwrap_or(&self.config.embedding_model)
2867            .to_string();
2868        let schema = self
2869            .unified_registry
2870            .get(&embedding_model_name)
2871            .or_else(|| self.unified_registry.find_by_name(&embedding_model_name))
2872            .ok_or_else(|| InferenceError::ModelNotFound(embedding_model_name.clone()))?
2873            .clone();
2874        Ok(schema.id)
2875    }
2876
2877    /// Load a backend through `cache`, self-healing a corrupt model cache on
2878    /// failure.
2879    ///
2880    /// Loads via `loader`. If the load fails *and* a deep integrity check finds
2881    /// provably-corrupt files under `model_dir`, those files are purged, the
2882    /// model is re-pulled via `repull`, and the load is retried exactly once. A
2883    /// load failure with intact files — an unsupported model, a transient FFI
2884    /// panic, OOM — surfaces unchanged: we re-pull only when we can *prove* the
2885    /// on-disk cache is the problem (the shared HF cache is mutated by other
2886    /// tools, so a load failure is genuinely ambiguous between "bad weights" and
2887    /// "bad luck"). The deep sha256 pass runs only on this rare failure path and
2888    /// is bounded to weight blobs (`verify_cache_file` short-circuits configs).
2889    ///
2890    /// `get_or_load` does not cache a failed load, so the retry is a clean
2891    /// second attempt rather than a cached error.
2892    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2893    async fn load_backend_healing<T, F, RF, R>(
2894        schema_id: &str,
2895        model_dir: std::path::PathBuf,
2896        cache: &backend_cache::BackendCache<T>,
2897        size: u64,
2898        reservation: &mut resource_policy::LocalLoadReservation,
2899        loader: F,
2900        repull: RF,
2901    ) -> Result<
2902        (
2903            backend_cache::CachedBackend<T>,
2904            backend_cache::BackendRetention,
2905        ),
2906        InferenceError,
2907    >
2908    where
2909        T: Send + 'static,
2910        F: Fn(&Path) -> Result<T, InferenceError>,
2911        RF: FnOnce() -> R,
2912        R: std::future::Future<Output = Result<std::path::PathBuf, InferenceError>>,
2913    {
2914        match cache.get_or_load_admitted(schema_id, size, reservation, || loader(&model_dir)) {
2915            Ok(admitted) => Ok(admitted),
2916            Err(load_err) => {
2917                // The heal is deliberately lock-free: `repull` (redownload_local)
2918                // takes the per-model `acquire_model_lock` itself, so wrapping
2919                // this branch in the same lock would deadlock. The only cost is
2920                // that two callers racing the very first load of the same corrupt
2921                // model both fail — the first purges+heals, the second sees
2922                // `purged == 0` and surfaces the error. Rare and fail-safe: the
2923                // next call loads the now-healed model cleanly.
2924                let purged = crate::download::purge_corrupt_cache_files(&model_dir);
2925                if purged == 0 {
2926                    // Cache is intact — not a corruption we can heal by re-pulling.
2927                    return Err(load_err);
2928                }
2929                tracing::warn!(
2930                    model = schema_id,
2931                    purged,
2932                    error = %load_err,
2933                    "backend load failed; purged corrupt cache files and re-pulling once"
2934                );
2935                let fresh_dir = repull().await?;
2936                let fresh_size = backend_cache::estimate_model_size(&fresh_dir);
2937                cache
2938                    .get_or_load_admitted(schema_id, fresh_size, reservation, || loader(&fresh_dir))
2939            }
2940        }
2941    }
2942
2943    /// Get or initialize the native MLX backend for a specific model.
2944    /// Returns a shared mutex handle — the caller locks it for the
2945    /// duration of an inference call so concurrent requests serialize.
2946    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2947    async fn ensure_mlx_backend(
2948        &self,
2949        schema: &ModelSchema,
2950        reservation: &mut resource_policy::LocalLoadReservation,
2951    ) -> Result<
2952        (
2953            backend_cache::CachedBackend<backend::MlxBackend>,
2954            backend_cache::BackendRetention,
2955        ),
2956        InferenceError,
2957    > {
2958        if !Self::supports_native_mlx(schema) {
2959            return Err(InferenceError::InferenceFailed(format!(
2960                "native MLX backend does not support {} ({}) yet; use vLLM-MLX or add a family-specific MLX backend",
2961                schema.name, schema.family
2962            )));
2963        }
2964
2965        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
2966        let size = backend_cache::estimate_model_size(&model_dir);
2967        if !reservation.authorizes_model(&schema.id) {
2968            return Err(InferenceError::InferenceFailed(format!(
2969                "local admission reservation does not authorize {}",
2970                schema.id
2971            )));
2972        }
2973        // Loader runs inside `get_or_load` only on a cache miss. Wrap it
2974        // in `catch_unwind` because MLX/accelerate occasionally panics
2975        // at the FFI boundary and we don't want the whole engine to die.
2976        let loader = |dir: &Path| {
2977            let dir = dir.to_path_buf();
2978            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2979                // LOCAL_ADMISSION_BOUNDARY:adaptive-local-dispatch
2980                backend::MlxBackend::load(&dir)
2981            }))
2982            .map_err(|e| {
2983                InferenceError::InferenceFailed(format!(
2984                    "MLX backend loading panicked (possible Metal/accelerate exception): {:?}",
2985                    e
2986                ))
2987            })?
2988        };
2989        Self::load_backend_healing(
2990            &schema.id,
2991            model_dir,
2992            &self.mlx_backends,
2993            size,
2994            reservation,
2995            loader,
2996            || self.unified_registry.redownload_local(&schema.id),
2997        )
2998        .await
2999    }
3000
3001    /// Load (and cache) a polymorphic in-process backend for a NEW-architecture
3002    /// MLX model — the trait-object analogue of `ensure_mlx_backend`, keyed
3003    /// into the separate `local_backends` cache. Dispatch on the model's
3004    /// `config.json` `model_type` lives in `backend::local::local_backend_for`.
3005    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3006    async fn ensure_local_backend(
3007        &self,
3008        schema: &ModelSchema,
3009        reservation: &mut resource_policy::LocalLoadReservation,
3010    ) -> Result<
3011        (
3012            backend_cache::CachedBackend<Box<dyn backend::local::LocalInferenceBackend>>,
3013            backend_cache::BackendRetention,
3014        ),
3015        InferenceError,
3016    > {
3017        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3018        let size = backend_cache::estimate_model_size(&model_dir);
3019        if !reservation.authorizes_model(&schema.id) {
3020            return Err(InferenceError::InferenceFailed(format!(
3021                "local admission reservation does not authorize {}",
3022                schema.id
3023            )));
3024        }
3025        let loader = |dir: &Path| {
3026            let dir = dir.to_path_buf();
3027            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3028                // LOCAL_ADMISSION_BOUNDARY:adaptive-local-dispatch
3029                backend::local::local_backend_for(&dir)
3030            }))
3031            .map_err(|e| {
3032                InferenceError::InferenceFailed(format!(
3033                    "local backend loading panicked (possible Metal/accelerate exception): {:?}",
3034                    e
3035                ))
3036            })?
3037        };
3038        Self::load_backend_healing(
3039            &schema.id,
3040            model_dir,
3041            &self.local_backends,
3042            size,
3043            reservation,
3044            loader,
3045            || self.unified_registry.redownload_local(&schema.id),
3046        )
3047        .await
3048    }
3049
3050    /// Clear the in-process KV / prefix cache of a loaded local model.
3051    ///
3052    /// Prefix reuse (`begin_prompt`) is a per-conversation optimization: it reuses
3053    /// the KV state of a shared token prefix across calls. When one engine is
3054    /// driven through a sequence of *independent* prompts (e.g. a benchmark's task
3055    /// suite), that reuse leaks decode state between unrelated conversations — and
3056    /// reusing cached KV instead of a fresh prefill introduces tiny numerical
3057    /// drift that can flip a greedy (temperature-0) token, making multi-step runs
3058    /// non-reproducible. Calling this between independent runs restores a clean
3059    /// slate. No-op for remote models or a backend that isn't currently loaded.
3060    pub async fn reset_local_kv_cache(&self, model_id: &str) {
3061        // The in-process `local_backends` cache (and its `ensure_local_backend`
3062        // loader) only exists on the native-MLX target; elsewhere there is no
3063        // such cache to clear, so this is a no-op.
3064        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3065        {
3066            let Some(schema) = self.unified_registry.get(model_id).cloned() else {
3067                return;
3068            };
3069            // Only the in-process backends (`Mlx`/`Local` GGUF) carry a KV cache;
3070            // remote sources have nothing to clear and must not be `ensure_local`-ed
3071            // (it would try to download weights).
3072            if !matches!(
3073                schema.source,
3074                ModelSource::Mlx { .. } | ModelSource::Local { .. }
3075            ) {
3076                return;
3077            }
3078            // Reset is observational maintenance, not a load boundary. Never
3079            // turn a cache miss into a cold model allocation.
3080            if !self.local_backends.contains(&schema.id) {
3081                return;
3082            }
3083            let Ok(mut reservation) = self.reserve_local_request(&schema, 0) else {
3084                return;
3085            };
3086            if let Ok((handle, _retention)) =
3087                self.ensure_local_backend(&schema, &mut reservation).await
3088            {
3089                if let Ok(mut guard) = handle.lock() {
3090                    guard.clear_kv_cache();
3091                }
3092            }
3093        }
3094        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3095        let _ = model_id;
3096    }
3097
3098    /// Pre-load a set of models into the MLX cache so the first real
3099    /// inference call doesn't pay the 1–14 s model-load latency. Safe
3100    /// to call multiple times; already-loaded models are no-ops.
3101    ///
3102    /// Handles both text-gen MLX backends (`mlx_backends`) and the
3103    /// image/video/tts caches. Pass the full `schema.id` values.
3104    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3105    pub async fn warm_up<S: AsRef<str>>(
3106        &self,
3107        schema_ids: &[S],
3108    ) -> Vec<Result<(), InferenceError>> {
3109        let mut results = Vec::with_capacity(schema_ids.len());
3110        for id in schema_ids {
3111            let id = id.as_ref();
3112            let outcome: Result<(), InferenceError> = async {
3113                let schema = self.unified_registry.get(id).cloned().ok_or_else(|| {
3114                    InferenceError::InferenceFailed(format!("warm_up: unknown schema id {id}"))
3115                })?;
3116                // LOCAL_ADMISSION_BOUNDARY:warm-up
3117                let mut reservation = self.reserve_local_request(&schema, 0)?;
3118                match schema.capabilities.first().copied() {
3119                    Some(ModelCapability::ImageGeneration) => {
3120                        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3121                        let size = backend_cache::estimate_model_size(&model_dir);
3122                        let _ = self.flux_cache.get_or_load_admitted(
3123                            &schema.id,
3124                            size,
3125                            &mut reservation,
3126                            || backend::mlx_flux::FluxBackend::load(&model_dir),
3127                        )?;
3128                    }
3129                    Some(ModelCapability::VideoGeneration) => {
3130                        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3131                        let size = backend_cache::estimate_model_size(&model_dir);
3132                        let _ = self.ltx_cache.get_or_load_admitted(
3133                            &schema.id,
3134                            size,
3135                            &mut reservation,
3136                            || {
3137                                // LOCAL_ADMISSION_BOUNDARY:video-dispatch
3138                                backend::mlx_ltx::LtxBackend::load(&model_dir)
3139                            },
3140                        )?;
3141                    }
3142                    Some(ModelCapability::TextToSpeech) => {
3143                        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3144                        let size = backend_cache::estimate_model_size(&model_dir);
3145                        let cache_key = reservation.model_id().to_string();
3146                        let _ = self.kokoro_cache.get_or_load_admitted(
3147                            &cache_key,
3148                            size,
3149                            &mut reservation,
3150                            || backend::mlx_kokoro::KokoroBackend::load(&model_dir),
3151                        )?;
3152                    }
3153                    _ => {
3154                        let _ = self.ensure_mlx_backend(&schema, &mut reservation).await?;
3155                    }
3156                }
3157                Ok(())
3158            }
3159            .await;
3160            results.push(outcome);
3161        }
3162        results
3163    }
3164
3165    /// No-op on non-macOS — MLX doesn't run here.
3166    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3167    pub async fn warm_up<S: AsRef<str>>(
3168        &self,
3169        _schema_ids: &[S],
3170    ) -> Vec<Result<(), InferenceError>> {
3171        Vec::new()
3172    }
3173
3174    /// Ensure the supervised `vllm-mlx` server for a `vllm-mlx/*` schema is
3175    /// running and return a copy whose endpoint points at the live loopback port.
3176    /// Non-vllm schemas pass through untouched. This is the seam that lets a
3177    /// server-backed (multimodal / unsupported-arch) model route exactly like an
3178    /// in-process one — the caller never starts a server or configures an endpoint.
3179    async fn vllm_live_schema(
3180        &self,
3181        schema: ModelSchema,
3182        reservation: Option<resource_policy::LocalLoadReservation>,
3183        context_tokens: usize,
3184    ) -> Result<(ModelSchema, Option<resource_policy::LocalLoadReservation>), InferenceError> {
3185        match &schema.source {
3186            ModelSource::ManagedVllmMlx { .. } => {}
3187            ModelSource::VllmMlx { .. } => return Ok((schema, None)),
3188            _ => return Ok((schema, None)),
3189        }
3190        // The gate begins before reaping/admission and ends only after the
3191        // ready process has been published resident. A concurrent request for
3192        // this model therefore waits, then reserves against the published
3193        // allocation instead of mistaking startup ownership for teardown.
3194        let _dispatch = self.vllm_pool.acquire_dispatch(&schema.id).await;
3195        self.vllm_pool
3196            .wait_for_teardown(&schema.id, std::time::Duration::from_secs(5))
3197            .await
3198            .map_err(InferenceError::InferenceFailed)?;
3199        // A dead leader may leave model-bearing descendants in its dedicated
3200        // process group. Reap/quarantine that group before taking a replacement
3201        // reservation; no new generation may peer-discount it while teardown
3202        // is pending.
3203        self.vllm_pool
3204            .reap_dead(&schema.id)
3205            .await
3206            .map_err(InferenceError::InferenceFailed)?;
3207        let mut reservation = match reservation {
3208            Some(reservation) if reservation.model_id() == schema.id => reservation,
3209            _ => self.reserve_local_request(&schema, context_tokens)?,
3210        };
3211        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
3212        let model_name = model_dir.display().to_string();
3213        let measured = backend_cache::estimate_model_size(&model_dir)
3214            .max(reservation.reconciled_weights_bytes());
3215        reservation
3216            .reconcile_measured_weights(measured)
3217            .map_err(InferenceError::from)?;
3218        // LOCAL_ADMISSION_BOUNDARY:supervised-vllm-mlx
3219        let endpoint = self
3220            .vllm_pool
3221            // `family` selects the server-side reasoning parser; without it a
3222            // reasoning model's chain-of-thought comes back as the answer.
3223            .ensure(&schema.id, &model_name, &reservation, &schema.family)
3224            .await
3225            .map_err(InferenceError::InferenceFailed)?;
3226        let allocation_id = resource_policy::vllm_process_allocation_id(&schema.id);
3227        reservation.publish_resident_weights_as(&allocation_id, measured);
3228        let mut schema = schema;
3229        schema.source = ModelSource::VllmMlx {
3230            endpoint,
3231            model_name,
3232        };
3233        Ok((schema, Some(reservation)))
3234    }
3235
3236    /// Stop idle supervised `vllm-mlx` servers. Driven by the same idle loop that
3237    /// evicts in-process backends; returns the number stopped.
3238    pub async fn evict_idle_vllm_servers(&self) -> usize {
3239        self.vllm_pool.evict_idle().await
3240    }
3241
3242    /// Sweep idle model backends out of every LRU cache so a quiet daemon
3243    /// releases its resident model working set instead of pinning it under
3244    /// the (large) capacity budget — capacity eviction never fires below
3245    /// the cap, so without this a single loaded model stays resident
3246    /// forever. Returns `(entries_evicted, bytes_evicted)` summed across
3247    /// all backend caches. Idle window is `CAR_INFERENCE_MODEL_IDLE_SECS`
3248    /// (default 300; 0 disables). Drive it on a timer. (car-releases#67)
3249    ///
3250    /// No-op on platforms without the MLX caches (they hold a single
3251    /// replaceable backend rather than an accumulating cache).
3252    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3253    pub fn evict_idle_backends(&self) -> (usize, u64) {
3254        let mut entries = 0usize;
3255        let mut bytes = 0u64;
3256        for (n, b) in [
3257            self.mlx_backends.evict_idle(),
3258            self.local_backends.evict_idle(),
3259            self.flux_cache.evict_idle(),
3260            self.ltx_cache.evict_idle(),
3261            self.kokoro_cache.evict_idle(),
3262        ] {
3263            entries += n;
3264            bytes = bytes.saturating_add(b);
3265        }
3266        (entries, bytes)
3267    }
3268
3269    /// No-op on non-macOS — there are no accumulating backend caches.
3270    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
3271    pub fn evict_idle_backends(&self) -> (usize, u64) {
3272        (0, 0)
3273    }
3274
3275    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3276    fn supports_native_mlx(schema: &ModelSchema) -> bool {
3277        matches!(schema.family.as_str(), "qwen3" | "qwen2.5-vl" | "qwen2-vl")
3278    }
3279
3280    fn catalog_registry_snapshot(&self) -> UnifiedRegistry {
3281        let mut registry = self.unified_registry.clone();
3282        // Startup/list/setup/health refreshes physical runtimes and local
3283        // weights, but credential-backed rows use environment presence or the
3284        // non-secret authority hint only.
3285        registry.refresh_availability();
3286        self.filter_disabled_local_models(&mut registry);
3287        registry
3288    }
3289
3290    fn filter_disabled_local_models(&self, registry: &mut UnifiedRegistry) {
3291        let disabled = registry
3292            .all()
3293            .filter(|schema| {
3294                schema.downloads_weights()
3295                    && !self
3296                        .model_management
3297                        .car_enabled(&schema.id)
3298                        .unwrap_or(false)
3299            })
3300            .map(|schema| schema.id.clone())
3301            .collect::<Vec<_>>();
3302        for model_id in disabled {
3303            registry.unregister(&model_id);
3304        }
3305    }
3306
3307    async fn routing_registry_snapshot_with_credential_failure(
3308        &self,
3309    ) -> (UnifiedRegistry, Option<RouteCredentialFailure>) {
3310        // The first explicit route/use is allowed to establish credential
3311        // truth. Parslee goes through Task 2's process-owned coordinator;
3312        // denial/cooldown remains distinct from an authoritative signed-out
3313        // result so passive observations are never cleared by an unreadable
3314        // Keychain.
3315        let parslee = car_auth::resolve_credential(car_auth::CredentialReadMode::Use).await;
3316        let (parslee_api_base, parslee_signed_out, credential_failure) = match &parslee {
3317            Ok(Some(credential)) => (Some(credential.api_base.as_str()), false, None),
3318            Ok(None) => (None, true, Some(parslee_signed_out_route_failure())),
3319            Err(error) => {
3320                let source_error = error.to_string();
3321                (
3322                    None,
3323                    false,
3324                    Some(RouteCredentialFailure {
3325                        summary: format!(
3326                            "Parslee {AUTH_STORE_UNREADABLE_MARKER} — unlock or grant access to the credential store, then retry"
3327                        ),
3328                        source_error,
3329                    }),
3330                )
3331            }
3332        };
3333        let mut registry = self.unified_registry.clone();
3334        registry.refresh_routing_availability(parslee_api_base, parslee_signed_out);
3335        self.filter_disabled_local_models(&mut registry);
3336        (registry, credential_failure)
3337    }
3338
3339    async fn routing_registry_snapshot(&self) -> UnifiedRegistry {
3340        self.routing_registry_snapshot_with_credential_failure()
3341            .await
3342            .0
3343    }
3344
3345    /// An explicit model does not need a global provider-credential sweep to
3346    /// be routed. Its backend resolves only its own credential at dispatch;
3347    /// local and external-runtime models resolve none. This keeps unrelated OS
3348    /// keychain latency off the managed-runtime admission and startup path.
3349    async fn request_routing_registry_snapshot(
3350        &self,
3351        requested_model: Option<&str>,
3352    ) -> UnifiedRegistry {
3353        if requested_model.is_some() {
3354            self.catalog_registry_snapshot()
3355        } else {
3356            self.routing_registry_snapshot().await
3357        }
3358    }
3359
3360    /// Route a prompt using the adaptive router (new). Returns full decision context.
3361    pub async fn route_adaptive(&self, prompt: &str) -> AdaptiveRoutingDecision {
3362        self.route_adaptive_with_intent(prompt, None).await
3363    }
3364
3365    /// Like [`route_adaptive`](Self::route_adaptive) but honors a caller
3366    /// [`IntentHint`] — notably `exclude_models`
3367    /// for adversarial-reviewer separation: "route me any capable model
3368    /// that is NOT the one that just did the work" (car#358). An excluded id
3369    /// is never chosen while any non-excluded capable model exists —
3370    /// including via the preferred-model override (skipped when it names an
3371    /// excluded model) and the cold-start fallbacks. The exclusion is soft:
3372    /// if excluding leaves nothing routable, an excluded model may still be
3373    /// returned as a last resort (a same-model review beats no review).
3374    pub async fn route_adaptive_with_intent(
3375        &self,
3376        prompt: &str,
3377        intent: Option<crate::intent::IntentHint>,
3378    ) -> AdaptiveRoutingDecision {
3379        if let Some(model) = self.preferred_model_for_capability(ModelCapability::Generate) {
3380            let exclude_set = self
3381                .adaptive_router
3382                .build_exclude_set(intent.as_ref(), &self.unified_registry);
3383            let overridden_is_excluded =
3384                Self::model_is_excluded(&exclude_set, &self.unified_registry, model);
3385            if !overridden_is_excluded {
3386                // Preferred-model routing never consulted refreshed
3387                // availability or credentials: it returned the configured
3388                // override either way. Read its context directly and avoid a
3389                // global credential/keychain sweep that cannot affect this
3390                // decision (and took ~182s in a scratch HOME).
3391                let ctx_len = self
3392                    .unified_registry
3393                    .get(model)
3394                    .or_else(|| self.unified_registry.find_by_name(model))
3395                    .map(|s| s.context_length)
3396                    .unwrap_or(0);
3397                return AdaptiveRoutingDecision {
3398                    model_id: model.to_string(),
3399                    model_name: model.to_string(),
3400                    task: InferenceTask::Generate,
3401                    complexity: TaskComplexity::assess(prompt),
3402                    reason: "preferred generation model override".into(),
3403                    strategy: RoutingStrategy::Explicit,
3404                    predicted_quality: 0.5,
3405                    fallbacks: vec![],
3406                    context_length: ctx_len,
3407                    needs_compaction: false,
3408                    candidates: vec![],
3409                };
3410            }
3411        }
3412        let routing_registry = self.routing_registry_snapshot().await;
3413        let tracker = self.outcome_tracker.read().await;
3414        match intent {
3415            Some(hint) => self
3416                .adaptive_router
3417                .route_with(crate::adaptive_router::RouteRequest {
3418                    intent: Some(&hint),
3419                    ..crate::adaptive_router::RouteRequest::new(prompt, &routing_registry, &tracker)
3420                }),
3421            None => self
3422                .adaptive_router
3423                .route(prompt, &routing_registry, &tracker),
3424        }
3425    }
3426
3427    /// Route a prompt to the best model without executing (legacy compat).
3428    pub fn route(&self, prompt: &str) -> RoutingDecision {
3429        self.router.route_generate(prompt, &self.registry)
3430    }
3431
3432    /// Estimate token count for a request against a specific model's context window.
3433    /// Returns (estimated_input_tokens, context_window_tokens, fits).
3434    ///
3435    /// Multimodal content blocks (image/video/audio, in `images` or in
3436    /// `messages` history) contribute provider-calibrated estimates via
3437    /// [`media_tokens`] — a minute of video is ~15.8K input tokens at
3438    /// Gemini's documented rate, not zero — and the multi-turn
3439    /// `messages` history's *text* is counted too (chars/4), not just
3440    /// its media. This feeds the adaptive router's window-fit /
3441    /// `needs_compaction` signal.
3442    pub fn estimated_tokens(
3443        &self,
3444        req: &GenerateRequest,
3445        model_id: Option<&str>,
3446    ) -> (usize, usize, bool) {
3447        let prompt_tokens = remote::estimate_tokens(&req.prompt);
3448        let context_tokens = req
3449            .context
3450            .as_ref()
3451            .map(|c| remote::estimate_tokens(c))
3452            .unwrap_or(0);
3453        let tools_tokens = req
3454            .tools
3455            .as_ref()
3456            .map(|t| remote::estimate_tokens(&serde_json::to_string(t).unwrap_or_default()))
3457            .unwrap_or(0);
3458        let media_tokens = media_tokens::request_media_and_history_tokens(
3459            req.images.as_deref(),
3460            req.messages.as_deref(),
3461        );
3462        let total_input = prompt_tokens + context_tokens + tools_tokens + media_tokens;
3463
3464        // Build the passive catalog snapshot ONLY when there is an id to look
3465        // up (car-releases#75). It refreshes local weights/runtime readiness
3466        // plus non-secret environment/authority hints; it performs no secret
3467        // store reads. All three in-crate callers pass `model_id: None`, so
3468        // constructing and discarding even that snapshot would be unnecessary;
3469        // the `and_then` short-circuits on None and `context_window` is 0 either
3470        // way. Request-time routing establishes authoritative credential truth
3471        // separately through `routing_registry_snapshot`.
3472        let context_window = match model_id {
3473            Some(id) => {
3474                let routing_registry = self.catalog_registry_snapshot();
3475                routing_registry
3476                    .get(id)
3477                    .or_else(|| routing_registry.find_by_name(id))
3478                    .map(|s| s.context_length)
3479                    .unwrap_or(0)
3480            }
3481            None => 0,
3482        };
3483
3484        let fits = context_window == 0 || (total_input + req.params.max_tokens) <= context_window;
3485        (total_input, context_window, fits)
3486    }
3487
3488    /// Normalize caller-supplied cache estimates to the prompt footprint the
3489    /// router is pricing. Cache reads and writes are mutually exclusive token
3490    /// buckets in [`CostModel::estimated_usd`], so their sum must never exceed
3491    /// the total estimated input. Zero stays zero: CAR does not infer a cache
3492    /// hit/write merely because protocol-level cache controls are enabled.
3493    fn routing_cache_estimates(req: &GenerateRequest, estimated_input: usize) -> (usize, usize) {
3494        let read = req
3495            .params
3496            .estimated_cache_read_input_tokens
3497            .min(estimated_input);
3498        let write = req
3499            .params
3500            .estimated_cache_write_input_tokens
3501            .min(estimated_input.saturating_sub(read));
3502        (read, write)
3503    }
3504
3505    /// The model's context window in tokens, or 0 if the id is unknown
3506    /// (unregistered). Public so a multi-turn driver (e.g. the assistant
3507    /// loop) can bound its running message history to the window *before*
3508    /// it overflows — an overflowed history pushes the model to its context
3509    /// limit and can truncate the original task provider-side.
3510    pub fn model_context_window(&self, model_id: &str) -> usize {
3511        let routing_registry = self.catalog_registry_snapshot();
3512        routing_registry
3513            .get(model_id)
3514            .or_else(|| routing_registry.find_by_name(model_id))
3515            .map(|s| s.context_length)
3516            .unwrap_or(0)
3517    }
3518
3519    /// Generate text with full tracking (tool_calls, usage, trace_id,
3520    /// latency, TTFT), plus Qwen3 hybrid-thinking recovery.
3521    ///
3522    /// Qwen3 (and other hybrid-thinking models) default to reasoning ON.
3523    /// With a small `max_tokens` budget the model can spend the entire
3524    /// budget inside an unclosed `<think>` block, so the strip pass returns
3525    /// empty text — `infer(prompt, model, 16)` then silently yields "" while
3526    /// a non-thinking model answers fine (car-releases#60, #62).
3527    ///
3528    /// When the caller left `thinking` on `Auto` (didn't explicitly opt into
3529    /// reasoning) and nothing usable came back, retry once with reasoning
3530    /// suppressed so the caller gets a direct answer — matching the CLI's
3531    /// `--thinking off` default, but for every FFI/daemon path. Either way,
3532    /// record *why* via `stop_reason` so an empty result is never silent.
3533    pub async fn generate_tracked(
3534        &self,
3535        req: GenerateRequest,
3536    ) -> Result<InferenceResult, InferenceError> {
3537        crate::offload::ensure_not_controlled_terminated()?;
3538        let catalog_snapshot = self
3539            .catalog_snapshot()
3540            .map_err(InferenceError::InferenceFailed)?;
3541        let recover = matches!(req.params.thinking, ThinkingMode::Auto);
3542        let mut result = self
3543            .generate_tracked_inner(req.clone(), &catalog_snapshot)
3544            .await?;
3545
3546        let action = classify_empty_pass(
3547            recover,
3548            result.stop_reason.as_deref(),
3549            &result.text,
3550            result.tool_calls.is_empty(),
3551        );
3552        let hit_decode_ceiling = action == EmptyPassAction::FailDecodeCeiling;
3553
3554        if action == EmptyPassAction::RetryWithoutThinking {
3555            result.stop_reason = Some("thinking_truncated".to_string());
3556            let mut retry = req;
3557            retry.params.thinking = ThinkingMode::Off;
3558            crate::offload::ensure_not_controlled_terminated()?;
3559            match self.generate_tracked_inner(retry, &catalog_snapshot).await {
3560                Ok(mut recovered) => {
3561                    if !recovered.text.trim().is_empty() || !recovered.tool_calls.is_empty() {
3562                        recovered.stop_reason = Some("thinking_recovered".to_string());
3563                        return Ok(recovered);
3564                    }
3565                }
3566                Err(error @ InferenceError::ControlledTermination) => return Err(error),
3567                Err(_) => {}
3568            }
3569        }
3570
3571        // A ceiling stop that produced nothing usable is a FAILED turn, and it
3572        // has to read like one. Returning `Ok` with empty text hands the caller
3573        // a turn it cannot act on: `car do` printed nothing and exited 0, which
3574        // is the same silence car#851 was reported for, just bounded. Partial
3575        // text still comes back as `Ok` — it is worth something to the caller.
3576        if hit_decode_ceiling && result.text.trim().is_empty() && result.tool_calls.is_empty() {
3577            return Err(InferenceError::InferenceFailed(format!(
3578                "local generation hit its {}s wall-clock ceiling before producing any output. \
3579                 The `local prefill starting` / `local decode in progress` log lines show where \
3580                 the time went — a large prompt can spend most of it on prefill. Try a smaller \
3581                 model, shorten the prompt, check for another process contending for the GPU, \
3582                 or raise the ceiling with CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables it).",
3583                local_decode_timeout().map_or(0, |t| t.as_secs())
3584            )));
3585        }
3586
3587        Ok(result)
3588    }
3589
3590    #[instrument(
3591        name = "inference.generate",
3592        skip_all,
3593        fields(
3594            model = tracing::field::Empty,
3595            max_tokens = req.params.max_tokens,
3596            prompt_tokens = tracing::field::Empty,
3597            completion_tokens = tracing::field::Empty,
3598            latency_ms = tracing::field::Empty,
3599        )
3600    )]
3601    async fn generate_tracked_inner(
3602        &self,
3603        mut req: GenerateRequest,
3604        catalog_snapshot: &CatalogSnapshot,
3605    ) -> Result<InferenceResult, InferenceError> {
3606        crate::offload::ensure_not_controlled_terminated()?;
3607        validate_expected_catalog_revision(&req, catalog_snapshot)?;
3608        let requested_model_id = exact_pinned_model_id(&req).map(str::to_string);
3609        if let Some(model_id) = requested_model_id.as_ref() {
3610            req.model = Some(model_id.clone());
3611            req.params.strict_model = true;
3612        }
3613        let start = Instant::now();
3614        let has_requested_route = req.model.is_some();
3615        let (routing_registry, initial_route_credential_failure) = if has_requested_route {
3616            (self.catalog_registry_snapshot(), None)
3617        } else {
3618            self.routing_registry_snapshot_with_credential_failure()
3619                .await
3620        };
3621        if let Some(requested) = requested_model_id.as_deref() {
3622            if routing_registry.get(requested).is_none() {
3623                return Err(InferenceError::ModelNotFound(requested.to_string()));
3624            }
3625        } else if let Some(requested) = req.model.as_deref() {
3626            if routing_registry
3627                .get(requested)
3628                .or_else(|| routing_registry.find_by_name(requested))
3629                .is_none()
3630            {
3631                return Err(InferenceError::ModelNotFound(requested.to_string()));
3632            }
3633        }
3634
3635        // Route using adaptive router (context-aware)
3636        let (estimated_input, _, _) = self.estimated_tokens(&req, None);
3637        // Full context footprint = input + the reserved output budget. The
3638        // router's fit / needs_compaction check compares this against each
3639        // model's context_length. Passing input ALONE (as it used to) let a
3640        // prompt that fits but leaves no room for `max_tokens` of output route
3641        // without a compaction signal, then overflow mid-generation. Matches
3642        // the engine's own `estimated_tokens` fit formula (input + max_tokens).
3643        // `estimated_input` is kept separately for token accounting below.
3644        let estimated_footprint = estimated_input.saturating_add(req.params.max_tokens);
3645        let (estimated_cache_read, estimated_cache_write) =
3646            Self::routing_cache_estimates(&req, estimated_input);
3647        let tracker_read = self.outcome_tracker.read().await;
3648        let has_tools = Self::request_has_tools(&req);
3649        let has_vision = Self::request_needs_vision(&req);
3650        let preferred_model = self
3651            .preferred_model_for_capability(ModelCapability::Generate)
3652            .map(str::to_string);
3653        let exclude_set = self
3654            .adaptive_router
3655            .build_exclude_set(req.intent.as_ref(), &routing_registry);
3656        let unpinned_override = self
3657            .lane_pin_for(&req, &routing_registry)
3658            .or(preferred_model)
3659            .filter(|model| !Self::model_is_excluded(&exclude_set, &routing_registry, model));
3660        let decision = match req.model.clone().or(unpinned_override) {
3661            Some(m) => {
3662                let ctx_len = routing_registry
3663                    .get(&m)
3664                    .or_else(|| routing_registry.find_by_name(&m))
3665                    .map(|s| s.context_length)
3666                    .unwrap_or(0);
3667                AdaptiveRoutingDecision {
3668                    model_id: m.clone(),
3669                    model_name: m.clone(),
3670                    task: InferenceTask::Generate,
3671                    complexity: TaskComplexity::assess(&req.prompt),
3672                    reason: "explicit model".into(),
3673                    strategy: RoutingStrategy::Explicit,
3674                    predicted_quality: 0.5,
3675                    fallbacks: vec![],
3676                    context_length: ctx_len,
3677                    needs_compaction: ctx_len > 0 && estimated_footprint > ctx_len,
3678                    candidates: vec![],
3679                }
3680            }
3681            None => self
3682                .adaptive_router
3683                .route_with(crate::adaptive_router::RouteRequest {
3684                    estimated_total_tokens: estimated_footprint,
3685                    estimated_input_tokens: estimated_input,
3686                    estimated_output_tokens: req.params.max_tokens,
3687                    estimated_cache_read_tokens: estimated_cache_read,
3688                    estimated_cache_write_tokens: estimated_cache_write,
3689                    has_tools,
3690                    has_vision,
3691                    workload: req.params.workload,
3692                    intent: req.intent.as_ref(),
3693                    ..crate::adaptive_router::RouteRequest::new(
3694                        &req.prompt,
3695                        &routing_registry,
3696                        &tracker_read,
3697                    )
3698                }),
3699        };
3700        drop(tracker_read);
3701
3702        if decision.model_id.is_empty() {
3703            let excluded_models = req
3704                .intent
3705                .as_ref()
3706                .map(|hint| hint.exclude_models.join(", "))
3707                .unwrap_or_default();
3708            return Err(InferenceError::NoEligibleModel { excluded_models });
3709        }
3710
3711        if decision.needs_compaction {
3712            tracing::info!(
3713                model = %decision.model_name,
3714                prompt_tokens = estimated_input,
3715                context_window = decision.context_length,
3716                "prompt exceeds model context window — compaction or truncation needed"
3717            );
3718        }
3719
3720        // NOTE: the outcome trace is opened per-candidate inside the fallback
3721        // loop below (`attempt_trace`), not once here. A single shared trace
3722        // mis-attributed a fallback success to the first model and let the
3723        // post-loop failure double-book the first candidate.
3724        debug!(
3725            model = %decision.model_name,
3726            strategy = ?decision.strategy,
3727            reason = %decision.reason,
3728            "adaptive-routed generate request"
3729        );
3730
3731        // Auto-enable extended thinking for complex tasks when the model supports it
3732        // and the caller hasn't explicitly set budget_tokens.
3733        let mut req = req;
3734
3735        // Default per-turn output budget from the resolved model when the
3736        // caller left it at the library default (4096). Prevents tool_use JSON
3737        // truncation runaways on long-horizon tasks (car-cli run_task) —
3738        // except for models decoded in-process, where the budget is wall clock.
3739        // See `resolved_max_tokens` (car#851).
3740        if let Some(schema) = routing_registry
3741            .get(&decision.model_id)
3742            .or_else(|| routing_registry.find_by_name(&decision.model_id))
3743        {
3744            req.params.max_tokens = resolved_max_tokens(req.params.max_tokens, schema);
3745        }
3746
3747        // Auto-enable extended/interleaved thinking for reasoning-heavy AND
3748        // CODING turns on models that support it. Coding turns arrive as
3749        // InferenceTask::Code (the coder/bench send IntentHint{task:Code}); they
3750        // never classify as TaskComplexity::Complex, which is exactly why coding
3751        // had 0 thinking budget on every turn. Code gets a higher budget ("high"
3752        // effort) than a general Complex task ("medium"). (F1, audit 2026-07-06.)
3753        // Key the coding budget on the caller's EXPLICIT intent, NOT the keyword
3754        // classifier's decision.task (see `is_explicit_code_intent`).
3755        let is_code_intent = is_explicit_code_intent(req.intent.as_ref());
3756        let is_complex = matches!(decision.complexity, TaskComplexity::Complex);
3757        if req.params.budget_tokens == 0 && (is_code_intent || is_complex) {
3758            // Same id-then-name resolution as the max-tokens defaulting
3759            // above — a name-only route must not silently skip the
3760            // auto-budget.
3761            let supports_thinking = routing_registry
3762                .get(&decision.model_id)
3763                .or_else(|| routing_registry.find_by_name(&decision.model_id))
3764                .map(|s| {
3765                    s.supported_params
3766                        .contains(&schema::GenerateParam::ExtendedThinking)
3767                })
3768                .unwrap_or(false);
3769            if let Some(budget) =
3770                auto_thinking_budget(is_code_intent, is_complex, supports_thinking)
3771            {
3772                req.params.budget_tokens = budget;
3773                tracing::info!(
3774                    model = %decision.model_name,
3775                    budget,
3776                    code_intent = is_code_intent,
3777                    "auto-enabled extended thinking"
3778                );
3779            }
3780        }
3781
3782        // Execute — dispatch to local or remote backend, with fallback on failure
3783        let mut models_to_try = vec![decision.model_id.clone()];
3784        models_to_try.extend(decision.fallbacks.iter().cloned());
3785
3786        // Resilience last resort: append an installed on-device model to the
3787        // tail of the chain when nothing already in it is local. An explicitly
3788        // requested / substituted model (e.g. the assistant's `parslee/advisor`)
3789        // ships with an EMPTY fallback list, so a single cloud failure — an
3790        // expired Parslee credential, a 401, an offline network — otherwise
3791        // errors out with "remaining=0" even on a machine with a working local
3792        // GPU model. Degrading to on-device beats failing. Only added when the
3793        // chain is entirely remote; a local primary/fallback already covers it.
3794        //
3795        // EXCEPT under a hard pin (`strict_model`): a caller that pinned a
3796        // specific backbone (the coder's `--model`, an A/B arm) needs the pinned
3797        // model or a loud error — NOT a silent swap to a weaker local model,
3798        // which manufactures fake results (a mid-run Parslee outage once
3799        // degraded a gpt-5.5 coder A/B to local Qwen and fabricated losses).
3800        let chain_has_local = models_to_try.iter().any(|m| {
3801            routing_registry
3802                .get(m)
3803                .or_else(|| routing_registry.find_by_name(m))
3804                .map(|s| s.is_local())
3805                .unwrap_or(false)
3806        });
3807        let mut local_last_resort_id = None;
3808        if should_append_local_last_resort(chain_has_local, req.params.strict_model) {
3809            // Tool-aware: for a tools-bearing turn, only a tool-capable local
3810            // model can serve it (a text-only one is dropped by the ToolUse
3811            // guard below), so require that capability before appending.
3812            if let Some(local) = self.first_installed_local_model(has_tools) {
3813                tracing::info!(
3814                    local_model = %local,
3815                    needs_tools = has_tools,
3816                    "appended on-device model as last-resort fallback (chain was remote-only)"
3817                );
3818                models_to_try.push(local.clone());
3819                local_last_resort_id = Some(local);
3820            }
3821        } else if !chain_has_local && req.params.strict_model {
3822            tracing::info!(
3823                model = %decision.model_id,
3824                "strict_model set — not degrading to on-device; a remote failure will surface as an error"
3825            );
3826        }
3827
3828        let mut last_error = None;
3829        // The FIRST candidate whose credential was rejected (401/403/expired),
3830        // remembered so a later candidate's success can announce the degrade
3831        // rather than quietly serving a different model (Parslee-ai/car#888).
3832        // Only meaningful when the chain goes on to succeed — an exhausted
3833        // chain already surfaces `auth_expired_recovery_hint`.
3834        let mut auth_dead_lane: Option<String> = None;
3835        // Unlike `auth_dead_lane` (success-path degrade metadata), this holds
3836        // the human-actionable cause for an exhausted chain. Each later
3837        // configured/attempted credential failure replaces an earlier one;
3838        // ambient missing-variable noise from unconfigured fallback providers
3839        // is ignored. The routing snapshot's signed-out / store-unreadable
3840        // pre-seed only survives when a Parslee route is actually part of this
3841        // chain. Another provider's outage or a local OOM cannot be repaired
3842        // with the Parslee login; actual provider auth failures below still
3843        // replace this slot regardless of which providers were selected.
3844        let mut route_credential_failure = if chain_includes_parslee_route(
3845            |m| {
3846                routing_registry
3847                    .get(m)
3848                    .or_else(|| routing_registry.find_by_name(m))
3849            },
3850            &models_to_try,
3851        ) {
3852            initial_route_credential_failure
3853        } else {
3854            None
3855        };
3856        // Every skipped lane, not only the auth-rejected one. This ordered
3857        // history stays separate from both credential slots — see
3858        // `InferenceResult::fallback_from`.
3859        let mut fallback_hops: Vec<FallbackFrom> = Vec::new();
3860
3861        // Pop-front queue (not `for .. in &models_to_try`) so the I4
3862        // failover below can promote a cross-provider fallback to the
3863        // front when the primary fails with a transient provider error.
3864        // A queue keeps the body's pre-existing `continue`s safe — the
3865        // candidate is already popped, so `continue` moves on instead of
3866        // retrying the same candidate forever (linus review, critical 1).
3867        let mut candidate_queue: std::collections::VecDeque<String> =
3868            models_to_try.iter().cloned().collect();
3869        let mut is_primary_attempt = true;
3870        while let Some(candidate_owned) = candidate_queue.pop_front() {
3871            crate::offload::ensure_not_controlled_terminated()?;
3872            let was_primary = is_primary_attempt;
3873            is_primary_attempt = false;
3874            let candidate_id = &candidate_owned;
3875            // `mut` is needed on the aarch64-macos cfg branch below;
3876            // other targets don't rebind.
3877            #[allow(unused_mut)]
3878            let mut schema = routing_registry
3879                .get(candidate_id)
3880                .or_else(|| routing_registry.find_by_name(candidate_id))
3881                .cloned();
3882
3883            // On Apple Silicon, redirect GGUF/Candle models to their MLX
3884            // equivalents. The adaptive router now pre-resolves this before
3885            // scoring (#333), so for router-proposed candidates this is a
3886            // no-op; it remains load-bearing for the legacy explicit-model
3887            // path (`req.model` set), which bypasses the router entirely. An
3888            // exact immutable `model_id` pin must dispatch the named row and
3889            // therefore deliberately bypasses this compatibility substitution.
3890            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3891            if requested_model_id.is_none() {
3892                if let Some(ref s) = schema {
3893                    if let Some(mlx_equiv) = routing_registry.resolve_mlx_equivalent(s) {
3894                        tracing::info!(
3895                            from = %s.id, to = %mlx_equiv.id,
3896                            "redirecting GGUF model to MLX equivalent on Apple Silicon"
3897                        );
3898                        schema = Some(mlx_equiv.clone());
3899                    }
3900                }
3901            }
3902
3903            // Bound before the capability guard below so a candidate skipped
3904            // there can be named in the fallback hop list — that guard is the
3905            // one capability mismatch car#1351 calls out, and it is reachable
3906            // by the two paths its own comment names.
3907            let candidate_name = schema
3908                .as_ref()
3909                .map(|s| s.name.clone())
3910                .unwrap_or_else(|| candidate_id.clone());
3911
3912            // Tool-capability guard (honest routing): a tools-bearing request
3913            // must land on a backend that actually parses tool calls. The
3914            // adaptive router filters on the ToolUse capability, but the
3915            // explicit-model path bypasses it and the cold-start "last resort"
3916            // can hand back a capability-lacking default. The in-process
3917            // mlx/candle generate path ignores `tools` entirely, so without
3918            // this guard the model would silently return prose for a tool
3919            // request. Skip this candidate (let fallback try a capable one); if
3920            // none qualifies, the loop surfaces UnsupportedMode below instead of
3921            // a misleading text answer.
3922            if has_tools
3923                && schema
3924                    .as_ref()
3925                    .map(|s| !s.has_capability(ModelCapability::ToolUse))
3926                    .unwrap_or(false)
3927            {
3928                let backend = schema
3929                    .as_ref()
3930                    .map(|s| if s.is_local() { "local" } else { "remote" })
3931                    .unwrap_or("unknown");
3932                tracing::warn!(
3933                    model = %candidate_id,
3934                    backend,
3935                    "tools requested but resolved model lacks ToolUse capability — skipping candidate"
3936                );
3937                let unsupported = InferenceError::UnsupportedMode {
3938                    mode: "tool_use",
3939                    backend,
3940                    reason: "resolved model does not support structured tool calls; configure a tool-capable model (a remote API model, or run the vllm-mlx OpenAI-compatible server)",
3941                };
3942                // Recorded: this is the capability mismatch car#1351 names by
3943                // hand, and the guard's own comment above says it is reachable
3944                // — the explicit-model path bypasses the router's filter and
3945                // the cold-start last resort can hand back a default without
3946                // ToolUse.
3947                record_fallback_from(&mut fallback_hops, &candidate_name, &unsupported);
3948                last_error = Some(unsupported);
3949                continue;
3950            }
3951
3952            // Book outcomes against the *resolved canonical id* (`schema.id`,
3953            // post-MLX-redirect) — not the raw `candidate_id` the caller
3954            // passed. An explicit alias like `claude-sonnet-4-6` and the
3955            // catalog id `anthropic/claude-sonnet-4-6:latest` resolve to the
3956            // same schema, so recording the raw alias fragmented the health
3957            // surface into two "models" for one physical model (the
3958            // high-volume non-streaming path's half of the split). Mirrors
3959            // `generate_stream_raw`'s `resolved_model_id`, which already does
3960            // this for the streaming path. Falls back to the raw id only when
3961            // the model is unknown to the registry.
3962            let resolved_id = schema
3963                .as_ref()
3964                .map(|s| s.id.clone())
3965                .unwrap_or_else(|| candidate_id.clone());
3966            let reported_model_used = if requested_model_id.is_some() {
3967                resolved_id.clone()
3968            } else {
3969                candidate_name.clone()
3970            };
3971            validate_expected_catalog_row(&req, catalog_snapshot, &resolved_id)?;
3972
3973            // An exact immutable id is an identity contract, not a request for
3974            // the nearest runnable implementation. Candle/GGUF execution is
3975            // disabled on Apple Silicon; fail before the worker boundary
3976            // instead of sending a legacy plain-model request that the worker
3977            // could silently redirect to the MLX twin.
3978            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
3979            if requested_model_id.is_some()
3980                && schema
3981                    .as_ref()
3982                    .is_some_and(|schema| matches!(schema.source, ModelSource::Local { .. }))
3983            {
3984                let pinned = InferenceError::InferenceFailed(format!(
3985                    "exact model_id `{resolved_id}` names a GGUF/Candle row, but Candle is disabled on Apple Silicon; MLX-equivalent substitution is disabled for exact pins"
3986                ));
3987                record_fallback_from(&mut fallback_hops, &candidate_name, &pinned);
3988                last_error = Some(pinned);
3989                continue;
3990            }
3991
3992            let is_remote = schema
3993                .as_ref()
3994                .map(|s| s.is_remote() || s.is_vllm_mlx())
3995                .unwrap_or(false);
3996            let is_codex_cli = schema.as_ref().map(|s| s.is_codex_cli()).unwrap_or(false);
3997            let is_delegated = schema.as_ref().map(|s| s.is_delegated()).unwrap_or(false);
3998
3999            // Open one outcome trace per candidate attempt, attributed to THIS
4000            // model id. Success (record_complete) and failure (record_failure)
4001            // both resolve this same trace, so each attempt books exactly one
4002            // outcome against the right model.
4003            let attempt_trace = {
4004                let mut tracker = self.outcome_tracker.write().await;
4005                tracker.record_start(&resolved_id, decision.task, &decision.reason)
4006            };
4007
4008            // Delegated dispatch (Parslee-ai/car-releases#24) — route
4009            // the synchronous path through the runner the same way
4010            // the streaming path does, then accumulate. Done before
4011            // the tools-context massaging because delegated models
4012            // own their own prompt construction.
4013            if is_delegated {
4014                let runner = match runner::current_inference_runner() {
4015                    Some(r) => r,
4016                    None => {
4017                        let msg = "model declares ModelSource::Delegated but no inference runner is registered";
4018                        self.outcome_tracker
4019                            .write()
4020                            .await
4021                            .record_failure(&attempt_trace, msg);
4022                        let unregistered = InferenceError::InferenceFailed(msg.into());
4023                        record_fallback_from(&mut fallback_hops, &candidate_name, &unregistered);
4024                        last_error = Some(unregistered);
4025                        continue;
4026                    }
4027                };
4028                let (tx, mut rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
4029                let emitter = runner::EventEmitter::new(tx);
4030                let runner_req = req.clone();
4031                let runner_handle = AbortOnDropTask(Some(tokio::spawn(async move {
4032                    runner.run(runner_req, emitter).await
4033                })));
4034                let mut accumulator = stream::StreamAccumulator::default();
4035                while let Some(evt) = rx.recv().await {
4036                    accumulator.push(&evt);
4037                }
4038                // Wait for the runner future so its return value is
4039                // observed. The accumulator is preferred when it has anything,
4040                // because a streaming runner's deltas are the authoritative
4041                // text; but a runner that emits NO events and answers with
4042                // `inference.runner.complete` alone is legitimate — a delegated
4043                // model returning a short non-streaming answer has nothing to
4044                // stream. Falling back to `RunnerResult` in that case is what
4045                // makes complete-alone terminal (Parslee-ai/car-releases#76).
4046                //
4047                // Discarding it was worse than losing the text. An empty result
4048                // trips the ThinkingMode::Auto truncation-recovery retry in
4049                // `generate_tracked`, which re-runs the WHOLE call — so the
4050                // runner is invoked a second time, wall time doubles, and
4051                // `latency_ms` (stamped in here, per leg) reports half of it.
4052                // `finish_with_usage`, not `finish` (#795). The accumulator
4053                // already captures `StreamEvent::Usage` — a runner that reports
4054                // counts had them collected and then thrown away one line before
4055                // they were needed, so every delegated call reported
4056                // `usage: null`. A consumer summing `total_tokens` read a silent
4057                // zero, which is worse than an error because it looks valid.
4058                //
4059                // Still `None` when the runner emits no usage event; that is
4060                // honest — CAR cannot know a foreign runner's tokenization — and
4061                // callers can fall back to their own estimator, which is what
4062                // `finish_with_usage` documents. The provider stop_reason comes
4063                // back on the same tuple and was being dropped too; it feeds
4064                // `InferenceResult::was_truncated`, which read as "not truncated"
4065                // for every delegated call.
4066                let (acc_text, acc_tool_calls, acc_usage, acc_stop_reason) =
4067                    accumulator.finish_with_usage();
4068                match runner_handle.join().await {
4069                    Ok(Ok(runner_result)) => {
4070                        let elapsed = start.elapsed().as_millis() as u64;
4071                        let acc_text = if acc_text.trim().is_empty() {
4072                            runner_result.text
4073                        } else {
4074                            acc_text
4075                        };
4076                        let acc_tool_calls = if acc_tool_calls.is_empty() {
4077                            runner_result.tool_calls
4078                        } else {
4079                            acc_tool_calls
4080                        };
4081                        // Estimate output tokens from the accumulated text so
4082                        // this delegated-runner path (NAPI/registered runners)
4083                        // records real token stats AND qualifies for the #312
4084                        // mechanical-success credit — a hardcoded 0 here failed
4085                        // the `output_tokens > 0` gate in outcome::sweep_pending
4086                        // and left these models stuck at the 0.5 EMA prior.
4087                        let est_out = acc_text.split_whitespace().count();
4088                        {
4089                            let mut tracker = self.outcome_tracker.write().await;
4090                            tracker.record_complete(
4091                                &attempt_trace,
4092                                elapsed,
4093                                estimated_input,
4094                                est_out,
4095                            );
4096                        }
4097                        let local_last_resort = report_local_last_resort_served(
4098                            local_last_resort_id.as_deref(),
4099                            candidate_id,
4100                            &resolved_id,
4101                        );
4102                        return Ok(InferenceResult {
4103                            text: acc_text,
4104                            tool_calls: acc_tool_calls,
4105                            bounding_boxes: vec![],
4106                            trace_id: attempt_trace,
4107                            model_used: reported_model_used,
4108                            model_identity: bound_model_identity(
4109                                catalog_snapshot,
4110                                requested_model_id.as_deref(),
4111                                &resolved_id,
4112                            )?,
4113                            latency_ms: elapsed,
4114                            time_to_first_token_ms: None,
4115                            // Whatever the runner reported (#795); None when it
4116                            // reported nothing, rather than a fabricated zero.
4117                            usage: acc_usage,
4118                            provider_output_items: vec![],
4119                            // Streaming thinking capture is a follow-up (stream.rs
4120                            // would accumulate thinking blocks); empty for now.
4121                            thinking: vec![],
4122                            stop_reason: acc_stop_reason,
4123                            auth_fallback_from: auth_dead_lane.clone(),
4124                            local_last_resort,
4125                            fallback_from: fallback_hops.clone(),
4126                        });
4127                    }
4128                    Ok(Err(e)) => {
4129                        self.outcome_tracker
4130                            .write()
4131                            .await
4132                            .record_failure(&attempt_trace, &e.to_string());
4133                        // Same bookkeeping as the main per-candidate failure arm
4134                        // below: a REJECTED credential here is a dead lane, and a
4135                        // later candidate's success must be able to say so.
4136                        // Classified from the SAME value that becomes
4137                        // `last_error`, so both failure records and the error
4138                        // the caller sees cannot describe different failures.
4139                        let failed = InferenceError::InferenceFailed(e.to_string());
4140                        record_auth_dead_lane(
4141                            &mut auth_dead_lane,
4142                            &candidate_name,
4143                            &failed.to_string(),
4144                        );
4145                        record_route_credential_failure(
4146                            &mut route_credential_failure,
4147                            &candidate_name,
4148                            &failed,
4149                            was_primary && has_requested_route,
4150                        );
4151                        record_fallback_from(&mut fallback_hops, &candidate_name, &failed);
4152                        last_error = Some(failed);
4153                        continue;
4154                    }
4155                    Err(join_err) => {
4156                        let msg = format!("runner task panicked: {join_err}");
4157                        self.outcome_tracker
4158                            .write()
4159                            .await
4160                            .record_failure(&attempt_trace, &msg);
4161                        // Recorded too: a panicked runner skips this candidate
4162                        // and the chain proceeds to a different backbone, which
4163                        // is a transition like any other. The arm above records
4164                        // and this one did not — two adjacent skips, one
4165                        // bookkeeping (linus review, car#1351).
4166                        let failed = InferenceError::InferenceFailed(msg);
4167                        record_fallback_from(&mut fallback_hops, &candidate_name, &failed);
4168                        last_error = Some(failed);
4169                        continue;
4170                    }
4171                }
4172            }
4173
4174            let has_tools = Self::request_has_tools(&req);
4175
4176            // Reinforce done tool instructions in context (fixes #10: empty done results)
4177            let context = if has_tools
4178                && req.tools.as_ref().is_some_and(|t| {
4179                    t.iter().any(|tool| {
4180                        tool.get("function")
4181                            .and_then(|f| f.get("name"))
4182                            .and_then(|n| n.as_str())
4183                            == Some("done")
4184                    })
4185                }) {
4186                let base = req.context.as_deref().unwrap_or("");
4187                Some(format!(
4188                    "{base}\n\nIMPORTANT: When calling the `done` tool, the `result` field MUST contain a DETAILED summary of everything you found and did. This is the ONLY output the user sees. Do NOT just say 'completed' — include specific findings, data, and conclusions."
4189                ))
4190            } else {
4191                req.context.clone()
4192            };
4193
4194            // Only the remote path produces thinking blocks; capture them here
4195            // (the tuple below stays 5-element so no other arm changes) and read
4196            // them into the InferenceResult after the match. (F1.)
4197            let mut captured_thinking: Vec<crate::tasks::generate::ThinkingBlock> = Vec::new();
4198            let mut captured_provider_output_items: Vec<serde_json::Value> = Vec::new();
4199            // LOCAL_ADMISSION_BOUNDARY:adaptive-local-dispatch
4200            // Reserve before either the in-process loader or the daemon-owned
4201            // worker receives the request. Explicit selections fail here and
4202            // never silently substitute another model; adaptive routing may
4203            // skip a blocked local candidate and records the reason.
4204            let mut local_reservation = if !is_delegated {
4205                match schema
4206                    .as_ref()
4207                    .filter(|schema| Self::reserve_in_outer_dispatch(schema))
4208                {
4209                    Some(local_schema) => {
4210                        match self.reserve_local_request(local_schema, estimated_footprint) {
4211                            Ok(reservation) => Some(reservation),
4212                            Err(error) if req.model.is_some() || req.params.strict_model => {
4213                                self.outcome_tracker
4214                                    .write()
4215                                    .await
4216                                    .record_capability_rejection(
4217                                        &attempt_trace,
4218                                        &error.to_string(),
4219                                    );
4220                                return Err(error);
4221                            }
4222                            Err(error) => {
4223                                tracing::warn!(
4224                                    model = %local_schema.id,
4225                                    error = %error,
4226                                    "adaptive local candidate blocked by resource policy; trying next route"
4227                                );
4228                                self.outcome_tracker
4229                                    .write()
4230                                    .await
4231                                    .record_capability_rejection(
4232                                        &attempt_trace,
4233                                        &error.to_string(),
4234                                    );
4235                                // A capability mismatch skips this candidate
4236                                // and the chain proceeds to a different
4237                                // backbone — a transition like any other, and
4238                                // one car#1351 names by hand.
4239                                record_fallback_from(&mut fallback_hops, &candidate_name, &error);
4240                                last_error = Some(error);
4241                                continue;
4242                            }
4243                        }
4244                    }
4245                    None => None,
4246                }
4247            } else {
4248                None
4249            };
4250            let result = if is_codex_cli {
4251                let schema_ref = schema
4252                    .as_ref()
4253                    .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4254                if req.tools.as_ref().is_some_and(|tools| !tools.is_empty()) {
4255                    Err(InferenceError::UnsupportedMode {
4256                        mode: "tools",
4257                        backend: "codex-cli",
4258                        reason: "the subscription-backed Codex source is a side-effect-free text generator and does not accept tools",
4259                    })
4260                } else if req
4261                    .messages
4262                    .as_ref()
4263                    .is_some_and(|messages| !messages.is_empty())
4264                {
4265                    Err(InferenceError::UnsupportedMode {
4266                        mode: "multi-turn-messages",
4267                        backend: "codex-cli",
4268                        reason: "the subscription-backed Codex source accepts one prompt plus optional context; it does not resume or replay conversations",
4269                    })
4270                } else if req.images.as_ref().is_some_and(|images| !images.is_empty())
4271                    || Self::request_has_video(&req)
4272                    || Self::request_has_audio(&req)
4273                {
4274                    Err(InferenceError::UnsupportedMode {
4275                        mode: "multimodal-content",
4276                        backend: "codex-cli",
4277                        reason: "the subscription-backed Codex source is text-only",
4278                    })
4279                } else if req.response_format.is_some() {
4280                    Err(InferenceError::UnsupportedMode {
4281                        mode: "response-format",
4282                        backend: "codex-cli",
4283                        reason: "the subscription-backed Codex source returns plain text and does not expose provider-enforced schemas",
4284                    })
4285                } else {
4286                    let model = match &schema_ref.source {
4287                        ModelSource::CodexCli { model } => model,
4288                        _ => unreachable!("is_codex_cli matched a different source"),
4289                    };
4290                    crate::backend::codex_cli::generate(
4291                        model,
4292                        &req.prompt,
4293                        context.as_deref(),
4294                        req.params.max_tokens,
4295                        schema_ref.context_length,
4296                    )
4297                    .await
4298                    .map(|output| (output.text, vec![], Some(output.usage), None, None))
4299                }
4300            } else if is_remote {
4301                // vllm-mlx: start + health-wait its supervised server, then route
4302                // to the live port. A startup failure is a per-candidate failure,
4303                // recorded like any other so the router can fall through.
4304                let (schema_val, _remote_request_reservation) = match self
4305                    .vllm_live_schema(
4306                        schema.unwrap(),
4307                        local_reservation.take(),
4308                        estimated_footprint,
4309                    )
4310                    .await
4311                {
4312                    Ok(pair) => pair,
4313                    Err(e) => {
4314                        self.outcome_tracker
4315                            .write()
4316                            .await
4317                            .record_failure(&attempt_trace, &e.to_string());
4318                        record_fallback_from(&mut fallback_hops, &candidate_name, &e);
4319                        last_error = Some(e);
4320                        continue;
4321                    }
4322                };
4323                let _ctx_len = schema_val.context_length;
4324                // Strip unsupported params based on model schema (#15).
4325                // Use -1.0 as sentinel: remote backends omit temperature entirely.
4326                let temperature = if !schema_val.supported_params.is_empty()
4327                    && !schema_val
4328                        .supported_params
4329                        .contains(&crate::schema::GenerateParam::Temperature)
4330                {
4331                    -1.0
4332                } else {
4333                    req.params.temperature
4334                };
4335
4336                // Always use the multi path so token usage is preserved on
4337                // both tool and non-tool requests. The bare `generate()` helper
4338                // in remote_backend wraps this same call but drops the usage
4339                // tuple, which breaks observability for plain text inference
4340                // (sc-3 in outcome 043).
4341                self.remote_backend
4342                    .generate_with_tools_multi(
4343                        &schema_val,
4344                        &req.prompt,
4345                        context.as_deref(),
4346                        temperature,
4347                        req.params.max_tokens,
4348                        req.tools.as_deref(),
4349                        req.images.as_deref(),
4350                        req.messages.as_deref(),
4351                        req.params.tool_choice.as_deref(),
4352                        req.params.parallel_tool_calls,
4353                        req.params.budget_tokens,
4354                        req.cache_control,
4355                        req.params.cache_ttl,
4356                        req.context_stable_prefix.as_deref(),
4357                        req.response_format.as_ref(),
4358                    )
4359                    .await
4360                    // Non-streaming remote APIs don't expose a
4361                    // first-token timestamp. Set TTFT=None and let
4362                    // streaming-aware callers measure it themselves
4363                    // via generate_tracked_stream. The 4th tuple element
4364                    // from generate_with_tools_multi is the provider stop_reason.
4365                    .map(|(t, c, thinking, provider_items, u, stop)| {
4366                        captured_thinking = thinking;
4367                        captured_provider_output_items = provider_items;
4368                        (t, c, u, None::<u64>, stop)
4369                    })
4370            } else if let Some(offload) = schema
4371                .as_ref()
4372                .filter(|schema| Self::supports_worker_offload(schema))
4373                .and_then(|_| crate::offload::current_local_offload())
4374            {
4375                // On-device generation is isolated in a worker subprocess
4376                // (car-releases#74): a large local MLX/Candle generation can
4377                // abort the process from the Metal/MLX C++ side, below every
4378                // Rust `catch_unwind`, taking the shared daemon down. When an
4379                // offloader is installed we hand it the fully-resolved request
4380                // instead of running the Metal decode loop here; a native abort
4381                // then kills only the worker (this returns `Err`, the daemon
4382                // fails one RPC and stays up, the next call respawns the worker).
4383                // Pin the model to the resolved id so the worker doesn't re-run
4384                // adaptive routing and land on a different backend.
4385                let mut offload_req = req.clone();
4386                offload_req.model = Some(resolved_id.clone());
4387                let schema_ref = schema
4388                    .as_ref()
4389                    .ok_or_else(|| InferenceError::ModelNotFound(resolved_id.clone()))?;
4390                let reservation = local_reservation.as_mut().ok_or_else(|| {
4391                    InferenceError::InferenceFailed(
4392                        "local worker dispatch missing admission reservation".into(),
4393                    )
4394                })?;
4395                if let Some(allocation_id) = offload.resident_allocation_id(&resolved_id) {
4396                    // A replacement offloader is a distinct process generation
4397                    // even when an older worker for the same logical model is
4398                    // still exiting. Reconcile against its exact owner before
4399                    // the child receives a load request so it cannot inherit a
4400                    // peer resident discount.
4401                    reservation.bind_allocation_id(&allocation_id);
4402                }
4403                let admission = self.prepare_worker_admission(schema_ref, reservation)?;
4404                match offload.generate_admitted(offload_req, admission).await {
4405                    Ok(outcome) => {
4406                        Self::reconcile_worker_residency(
4407                            offload.as_ref(),
4408                            &resolved_id,
4409                            &outcome.residency,
4410                            outcome.retention,
4411                            reservation,
4412                        )
4413                        .await?;
4414                        let ir = outcome.result;
4415                        // The worker already ran the full tracked generation
4416                        // (tool-call parsing, thinking capture, stop reason);
4417                        // adapt its InferenceResult into this arm's tuple and
4418                        // let the shared post-dispatch code (outcome tracking,
4419                        // grounding parse, InferenceResult assembly with the
4420                        // daemon-side trace_id/latency) run unchanged.
4421                        captured_thinking = ir.thinking;
4422                        captured_provider_output_items = ir.provider_output_items;
4423                        Ok((
4424                            ir.text,
4425                            ir.tool_calls,
4426                            ir.usage,
4427                            ir.time_to_first_token_ms,
4428                            ir.stop_reason,
4429                        ))
4430                    }
4431                    Err(e) => Err(e),
4432                }
4433            } else {
4434                #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
4435                {
4436                    // On Apple Silicon, all local models must go through MLX.
4437                    // GGUF models were redirected to MLX equivalents above;
4438                    // if we still have a non-MLX model here, it has no MLX equivalent.
4439                    let schema_ref = schema
4440                        .as_ref()
4441                        .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4442
4443                    // Apple FoundationModels — on-device system model.
4444                    // Text generation, tool calling (capture-and-return
4445                    // bridge), and JsonSchema-constrained output are
4446                    // wired; vision/audio/video are rejected upstream
4447                    // (the public FM API is text-only) so the router
4448                    // falls through to a richer model rather than
4449                    // silently dropping capabilities.
4450                    if schema_ref.is_foundation_models() {
4451                        if Self::request_has_video(&req)
4452                            || Self::request_has_audio(&req)
4453                            || req.images.as_ref().is_some_and(|imgs| !imgs.is_empty())
4454                        {
4455                            Err(InferenceError::UnsupportedMode {
4456                                mode: "multimodal-content",
4457                                backend: "foundation-models",
4458                                reason: "the FoundationModels bridge currently exposes text-only \
4459                                     generation — route image/audio/video to a remote VL model",
4460                            })
4461                        } else if has_tools {
4462                            // One FM turn is either tool-enabled or
4463                            // schema-constrained, not both. Tools win;
4464                            // a JsonSchema response_format is dropped
4465                            // loudly (same policy as the Anthropic
4466                            // handler, which has no native field).
4467                            if req.response_format.is_some() {
4468                                tracing::warn!(
4469                                    "FoundationModels: response_format is ignored when tools \
4470                                     are present — one turn is either tool-enabled or \
4471                                     schema-constrained"
4472                                );
4473                            }
4474                            let prompt = req.prompt.clone();
4475                            let instructions = context.clone();
4476                            let tools_defs = req.tools.clone().unwrap_or_default();
4477                            let max_tokens = req.params.max_tokens as u32;
4478                            let temperature = req.params.temperature;
4479                            tokio::task::spawn_blocking(move || {
4480                                crate::backend::foundation_models::generate_with_tools(
4481                                    &prompt,
4482                                    instructions.as_deref(),
4483                                    &tools_defs,
4484                                    max_tokens,
4485                                    temperature as f32,
4486                                )
4487                            })
4488                            .await
4489                            .map_err(|e| {
4490                                InferenceError::InferenceFailed(format!(
4491                                    "FoundationModels task panicked: {e}"
4492                                ))
4493                            })
4494                            .and_then(|r| r)
4495                            .map(|(text, calls)| (text, calls, None, None, None))
4496                        } else if let Some(crate::tasks::generate::ResponseFormat::JsonSchema {
4497                            schema,
4498                            ..
4499                        }) = &req.response_format
4500                        {
4501                            // Native constrained decoding via
4502                            // DynamicGenerationSchema — the framework
4503                            // enforces the schema, not the prompt.
4504                            let prompt = req.prompt.clone();
4505                            let instructions = context.clone();
4506                            let schema_val = schema.clone();
4507                            let max_tokens = req.params.max_tokens as u32;
4508                            let temperature = req.params.temperature;
4509                            tokio::task::spawn_blocking(move || {
4510                                crate::backend::foundation_models::generate_structured(
4511                                    &prompt,
4512                                    instructions.as_deref(),
4513                                    &schema_val,
4514                                    max_tokens,
4515                                    temperature as f32,
4516                                )
4517                            })
4518                            .await
4519                            .map_err(|e| {
4520                                InferenceError::InferenceFailed(format!(
4521                                    "FoundationModels task panicked: {e}"
4522                                ))
4523                            })
4524                            .and_then(|r| r)
4525                            .map(|text| (text, vec![], None, None, None))
4526                        } else {
4527                            // Plain text turn. JsonObject (schema-free
4528                            // JSON mode) has no native FM equivalent —
4529                            // enforce by instruction, loudly.
4530                            let instructions = if matches!(
4531                                req.response_format,
4532                                Some(crate::tasks::generate::ResponseFormat::JsonObject)
4533                            ) {
4534                                tracing::warn!(
4535                                    "FoundationModels: JsonObject response_format has no native \
4536                                     constrained mode — enforcing via instruction injection"
4537                                );
4538                                let base = context.clone().unwrap_or_default();
4539                                Some(format!(
4540                                    "{base}\n\nRespond with a single valid JSON object and \
4541                                     nothing else."
4542                                ))
4543                            } else {
4544                                context.clone()
4545                            };
4546                            let prompt = req.prompt.clone();
4547                            let max_tokens = req.params.max_tokens as u32;
4548                            let temperature = req.params.temperature;
4549                            tokio::task::spawn_blocking(move || {
4550                                crate::backend::foundation_models::generate(
4551                                    &prompt,
4552                                    instructions.as_deref(),
4553                                    max_tokens,
4554                                    temperature as f32,
4555                                )
4556                            })
4557                            .await
4558                            .map_err(|e| {
4559                                InferenceError::InferenceFailed(format!(
4560                                    "FoundationModels task panicked: {e}"
4561                                ))
4562                            })
4563                            .and_then(|r| r)
4564                            .map(|text| (text, vec![], None, None, None))
4565                        }
4566                    } else if !schema_ref.is_mlx() {
4567                        Err(InferenceError::InferenceFailed(format!(
4568                            "model '{}' has no MLX equivalent; Candle backend disabled on Apple Silicon",
4569                            schema_ref.id
4570                        )))
4571                    } else if schema_ref.tags.iter().any(|t| t == "mlx-vlm-cli") {
4572                        // Schemas explicitly tagged for the
4573                        // mlx-vlm CLI shell-out path skip the
4574                        // wasteful native-MLX text-tower load
4575                        // entirely — `mlx_vlm.generate` loads its
4576                        // own weights from the HF cache and
4577                        // performs vision tokenization that the
4578                        // native backend does not. Falls through
4579                        // to the same error message as the
4580                        // post-load fallback when mlx-vlm is not
4581                        // installed, so the user-facing failure
4582                        // is consistent.
4583                        let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
4584                        if !has_images {
4585                            return Err(InferenceError::UnsupportedMode {
4586                                mode: "text-only-on-mlx-vlm-id",
4587                                backend: "mlx-vlm-cli",
4588                                reason: "the `mlx-vlm/...` model IDs route exclusively \
4589                                     through the mlx-vlm CLI for image inference. \
4590                                     For text-only generation, route to a Qwen3 \
4591                                     text model (`mlx/qwen3-4b:4bit` etc.) — the \
4592                                     CLI shell-out has higher latency than the \
4593                                     in-process MLX text tower.",
4594                            });
4595                        }
4596                        let vlm_status = crate::backend::mlx_vlm_cli::runtime_status();
4597                        if !vlm_status.is_available() {
4598                            return Err(InferenceError::InferenceFailed(vlm_status.user_message()));
4599                        }
4600                        let model_dir = self.unified_registry.ensure_local(&schema_ref.id).await?;
4601                        let reservation = local_reservation
4602                            .as_mut()
4603                            .expect("local VLM branch has admission reservation");
4604                        Self::reconcile_transient_local_allocation(
4605                            reservation,
4606                            backend_cache::estimate_model_size(&model_dir),
4607                        )?;
4608                        let detached_lease = reservation.detached_lease();
4609                        let repo = match &schema_ref.source {
4610                            crate::schema::ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
4611                            _ => {
4612                                return Err(InferenceError::InferenceFailed(format!(
4613                                    "model '{}' is tagged mlx-vlm-cli but its \
4614                                     source isn't ModelSource::Mlx — registry bug",
4615                                    schema_ref.id
4616                                )));
4617                            }
4618                        };
4619                        let imgs = req.images.clone().unwrap_or_default();
4620                        let temp = req.params.temperature;
4621                        let max_t = req.params.max_tokens;
4622                        let prompt = req.prompt.clone();
4623                        let (text, cli_usage) = run_admitted_blocking(detached_lease, move || {
4624                            crate::backend::mlx_vlm_cli::generate(
4625                                &repo, &prompt, &imgs, temp, max_t,
4626                            )
4627                        })
4628                        .await
4629                        .map_err(|e| {
4630                            InferenceError::InferenceFailed(format!(
4631                                "mlx_vlm CLI task panicked: {e}"
4632                            ))
4633                        })??;
4634                        let bounding_boxes = parse_boxes(&text);
4635                        let latency_ms = start.elapsed().as_millis() as u64;
4636                        // mlx-vlm prints its own `Prompt:`/`Generation:` token
4637                        // counts and CAR used to discard them with the rest of
4638                        // the perf summary, hardcoding `usage: None`
4639                        // (Parslee-ai/car#795). They're worth recovering rather
4640                        // than estimating: the prompt count includes the image
4641                        // patches, which nothing on this side can reproduce —
4642                        // the vision tower lives in the Python process. Still
4643                        // `None` when the summary didn't parse; an absent count
4644                        // is honest, a zero is not.
4645                        let usage = cli_usage.map(|u| TokenUsage {
4646                            prompt_tokens: u.prompt_tokens,
4647                            completion_tokens: u.completion_tokens,
4648                            total_tokens: u.prompt_tokens + u.completion_tokens,
4649                            context_window: schema_ref.context_length as u64,
4650                            // Local in-process inference has no remote prompt cache.
4651                            ..Default::default()
4652                        });
4653                        {
4654                            // Real counts when the CLI reported them, else the
4655                            // word-count estimate — which still has to be
4656                            // non-zero, because a hardcoded 0 fails the #312
4657                            // mechanical-success gate in outcome::sweep_pending
4658                            // (same trap as the delegated-runner path above).
4659                            let (in_tokens, out_tokens) = match &usage {
4660                                Some(u) => (u.prompt_tokens as usize, u.completion_tokens as usize),
4661                                None => (estimated_input, text.split_whitespace().count()),
4662                            };
4663                            let mut tracker = self.outcome_tracker.write().await;
4664                            tracker.record_complete(
4665                                &attempt_trace,
4666                                latency_ms,
4667                                in_tokens,
4668                                out_tokens,
4669                            );
4670                        }
4671                        let local_last_resort = report_local_last_resort_served(
4672                            local_last_resort_id.as_deref(),
4673                            candidate_id,
4674                            &resolved_id,
4675                        );
4676                        return Ok(InferenceResult {
4677                            text,
4678                            tool_calls: vec![],
4679                            bounding_boxes,
4680                            trace_id: attempt_trace,
4681                            model_used: schema_ref.id.clone(),
4682                            model_identity: bound_model_identity(
4683                                catalog_snapshot,
4684                                requested_model_id.as_deref(),
4685                                &resolved_id,
4686                            )?,
4687                            latency_ms,
4688                            time_to_first_token_ms: None,
4689                            usage,
4690                            provider_output_items: Vec::new(),
4691                            thinking: Vec::new(), // local model — no thinking blocks
4692                            stop_reason: None,
4693                            auth_fallback_from: auth_dead_lane.clone(),
4694                            local_last_resort,
4695                            fallback_from: fallback_hops.clone(),
4696                        });
4697                    } else if !Self::supports_native_mlx(schema_ref) {
4698                        // A local MLX checkpoint the dedicated Qwen `MlxBackend`
4699                        // doesn't service (e.g. Gemma 4) routes through the
4700                        // polymorphic local-backend dispatch + shared decode
4701                        // loop. Text-only for now: reject multimodal content
4702                        // with a precise UnsupportedMode rather than silently
4703                        // dropping it.
4704                        if req.images.as_ref().is_some_and(|i| !i.is_empty())
4705                            || Self::request_has_video(&req)
4706                            || Self::request_has_audio(&req)
4707                        {
4708                            return Err(InferenceError::UnsupportedMode {
4709                                mode: "multimodal-content-block",
4710                                backend: "native-mlx-local",
4711                                reason: "this in-process MLX backend is text-only; route \
4712                                     image/video/audio understanding to a vLLM-MLX or remote \
4713                                     multimodal model",
4714                            });
4715                        }
4716                        // NB: do *not* pre-render with `render_for_local_backend`
4717                        // here. That helper flattens `messages`/`tools` into the
4718                        // Qwen3 wire format and clears the structured fields —
4719                        // correct for the native Qwen `MlxBackend`, fatal for a
4720                        // backend with its own chat template (Gemma 4 would then
4721                        // render a Qwen-formatted blob through the Gemma grammar
4722                        // and ramble). `generate_local` defers rendering to the
4723                        // backend's `render_prompt`, which sees the intact
4724                        // structured request (its own template, or the Qwen
4725                        // `render_chat_prompt` default for template-less backends).
4726                        self.generate_local(
4727                            req.clone(),
4728                            &schema_ref.id,
4729                            local_reservation
4730                                .as_mut()
4731                                .expect("local branch has admission reservation"),
4732                        )
4733                        .await
4734                    } else {
4735                        // Load the backend first so we can ask it what
4736                        // it's actually able to execute. VL checkpoints
4737                        // currently load as text-only towers (see the
4738                        // `language_model.` prefix strip in backend/mlx.rs);
4739                        // the backend's `supports_capability(Vision)`
4740                        // returns false until GH #58 wires the vision
4741                        // tower. A registry-level capability claim is
4742                        // an aspiration for routing; the backend answer
4743                        // is the execution contract.
4744                        let (handle, _retention) = self
4745                            .ensure_mlx_backend(
4746                                schema_ref,
4747                                local_reservation
4748                                    .as_mut()
4749                                    .expect("local branch has admission reservation"),
4750                            )
4751                            .await?;
4752                        // Native MLX path doesn't have a video
4753                        // tokenization pipeline yet. Reject video
4754                        // content blocks up front with a precise
4755                        // UnsupportedMode so callers don't silently
4756                        // get a text-only reply.
4757                        if Self::request_has_video(&req) {
4758                            return Err(InferenceError::UnsupportedMode {
4759                                mode: "video-content-block",
4760                                backend: "native-mlx-qwen25vl",
4761                                reason: "Qwen2.5-VL video understanding is on the request surface \
4762                                     but the video-tokenization path (frame sampling + merger) \
4763                                     is not yet wired; route to a remote VL provider for now",
4764                            });
4765                        }
4766                        if Self::request_has_audio(&req) {
4767                            return Err(InferenceError::UnsupportedMode {
4768                                mode: "audio-content-block",
4769                                backend: "native-mlx-qwen25vl",
4770                                reason: "audio understanding is on the request surface (Gemma 4 \
4771                                     E2B/E4B and Gemini accept it) but the native MLX path \
4772                                     for this model does not — route to Gemini or Gemma-4",
4773                            });
4774                        }
4775                        let has_images = req.images.as_ref().is_some_and(|imgs| !imgs.is_empty());
4776                        if has_images {
4777                            let can_do_vision = {
4778                                let guard = handle.lock().map_err(|_| {
4779                                    InferenceError::InferenceFailed(
4780                                        "MLX backend mutex poisoned".into(),
4781                                    )
4782                                })?;
4783                                guard.supports_capability(crate::schema::ModelCapability::Vision)
4784                            };
4785                            if !can_do_vision {
4786                                // The `mlx-vlm-cli`-tagged route handled
4787                                // above is the primary fix for #115; if
4788                                // we landed here it means the schema is
4789                                // a non-tagged `ModelSource::Mlx` (e.g.
4790                                // a user-registered custom model that
4791                                // doesn't advertise the CLI route). The
4792                                // error message points them at the
4793                                // tagged catalog IDs rather than
4794                                // claiming nothing local works.
4795                                return Err(InferenceError::UnsupportedMode {
4796                                    mode: "image-content-block",
4797                                    backend: "native-mlx-text",
4798                                    reason: "this MLX backend is a plain Qwen3 text tower. \
4799                                         For local image inference, route to \
4800                                         `mlx-vlm/qwen3-vl-2b:bf16` or another `mlx-vlm/...` \
4801                                         catalog ID so CAR shells out to `mlx_vlm.generate`. \
4802                                         Alternatives: a local vLLM-MLX VLM server, or a \
4803                                         remote VL model. (#115)",
4804                                });
4805                            }
4806                        }
4807                        self.generate_mlx(
4808                            render_for_local_backend(req.clone()),
4809                            &schema_ref.id,
4810                            local_reservation
4811                                .as_mut()
4812                                .expect("local branch has admission reservation"),
4813                        )
4814                        .await
4815                        .map(|(text, usage, ttft, stop)| (text, vec![], usage, ttft, stop))
4816                    }
4817                }
4818
4819                #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
4820                {
4821                    let schema_ref = schema
4822                        .as_ref()
4823                        .ok_or_else(|| InferenceError::ModelNotFound(candidate_id.clone()))?;
4824                    match self
4825                        .ensure_backend(
4826                            schema_ref,
4827                            local_reservation
4828                                .as_mut()
4829                                .expect("local branch has admission reservation"),
4830                        )
4831                        .await
4832                    {
4833                        Ok(()) => {
4834                            let mut write = self.backend.write().await;
4835                            let backend = write.get_mut(&schema_ref.id).ok_or_else(|| {
4836                                InferenceError::InferenceFailed(format!(
4837                                    "candle backend missing after ensure_backend for {}",
4838                                    schema_ref.id
4839                                ))
4840                            })?;
4841                            // Real counts, not `None`. The candle loop knows
4842                            // both — post-truncation prompt length and the
4843                            // number of tokens it sampled — and discarding them
4844                            // made every local call on this platform report
4845                            // `usage: null` (Parslee-ai/car#795).
4846                            let ctx_window = backend.context_length().unwrap_or(0) as u64;
4847                            tasks::generate::generate(
4848                                backend,
4849                                render_for_local_backend(req.clone()),
4850                            )
4851                            .await
4852                            .map(
4853                                |(text, ttft, prompt_tokens, completion_tokens)| {
4854                                    let usage = TokenUsage {
4855                                        prompt_tokens: prompt_tokens as u64,
4856                                        completion_tokens: completion_tokens as u64,
4857                                        total_tokens: (prompt_tokens + completion_tokens) as u64,
4858                                        context_window: ctx_window,
4859                                        // Local in-process inference has no remote
4860                                        // prompt cache.
4861                                        ..Default::default()
4862                                    };
4863                                    (text, vec![], Some(usage), ttft, None)
4864                                },
4865                            )
4866                        }
4867                        Err(e) => Err(e),
4868                    }
4869                }
4870            };
4871
4872            match result {
4873                Ok((text, mut tool_calls, usage, time_to_first_token_ms, stop_reason)) => {
4874                    // In-process (MLX/candle) backends return prose only — they
4875                    // don't parse structured tool calls. When the caller asked
4876                    // for tools, recover any <tool_call> blocks the local model
4877                    // emitted in the text into structured tool_calls (and strip
4878                    // them from the visible text). Remote/delegated backends
4879                    // already populate tool_calls, so this is a no-op for them.
4880                    let text = if !is_remote
4881                        && !is_delegated
4882                        && req.tools.is_some()
4883                        && tool_calls.is_empty()
4884                    {
4885                        let (clean, parsed) = tasks::generate::parse_tool_calls(&text);
4886                        tool_calls = parsed;
4887                        clean
4888                    } else {
4889                        text
4890                    };
4891                    let latency_ms = start.elapsed().as_millis() as u64;
4892                    let estimated_tokens = usage
4893                        .as_ref()
4894                        .map(|u| u.completion_tokens as usize)
4895                        .unwrap_or_else(|| text.split_whitespace().count());
4896                    // Prefer the provider's real prompt-token count (remote);
4897                    // fall back to the router's pre-call estimate (local, where
4898                    // usage is None). Previously hardcoded 0, which zeroed
4899                    // total_input_tokens and corrupted quality_per_1k_tokens.
4900                    let input_tokens = usage
4901                        .as_ref()
4902                        .map(|u| u.prompt_tokens as usize)
4903                        .unwrap_or(estimated_input);
4904                    // Prompt-cache split (Anthropic): `input_tokens` above is
4905                    // the uncached prefix only, so carry the cached buckets
4906                    // separately for cache-aware cost accounting. Both 0 for
4907                    // providers/paths without prompt caching.
4908                    let (cache_read, cache_creation) = usage
4909                        .as_ref()
4910                        .map(|u| {
4911                            (
4912                                u.cache_read_input_tokens as usize,
4913                                u.cache_creation_input_tokens as usize,
4914                            )
4915                        })
4916                        .unwrap_or((0, 0));
4917                    {
4918                        let mut tracker = self.outcome_tracker.write().await;
4919                        tracker.record_complete_cached(
4920                            &attempt_trace,
4921                            latency_ms,
4922                            input_tokens,
4923                            estimated_tokens,
4924                            cache_read,
4925                            cache_creation,
4926                        );
4927                    }
4928                    // Circuit breaker: record success (#25). Key on the
4929                    // canonical `resolved_id` — the breaker's read side
4930                    // (`allow_request(&m.id)`, adaptive_router.rs) checks the
4931                    // canonical schema id, so booking under the raw alias here
4932                    // would mean the breaker never trips for aliased calls.
4933                    if let Ok(mut cb) = self.adaptive_router.circuit_breakers.lock() {
4934                        cb.record_success(&resolved_id);
4935                    }
4936                    // Auto-persist profiles after each successful call
4937                    self.auto_save_outcomes().await;
4938
4939                    // Record deferred span fields now that we have the result
4940                    let span = tracing::Span::current();
4941                    span.record("model", candidate_name.as_str());
4942                    span.record("latency_ms", latency_ms);
4943                    if let Some(ttft) = time_to_first_token_ms {
4944                        span.record("ttft_ms", ttft);
4945                    }
4946                    if let Some(ref u) = usage {
4947                        span.record("prompt_tokens", u.prompt_tokens);
4948                        span.record("completion_tokens", u.completion_tokens);
4949                    }
4950
4951                    // Parse Qwen2.5-VL grounding spans out of the
4952                    // text output. Empty vec on anything else.
4953                    let bounding_boxes = tasks::grounding::parse_boxes(&text);
4954                    let local_last_resort = report_local_last_resort_served(
4955                        local_last_resort_id.as_deref(),
4956                        candidate_id,
4957                        &resolved_id,
4958                    );
4959                    return Ok(InferenceResult {
4960                        text,
4961                        tool_calls,
4962                        bounding_boxes,
4963                        trace_id: attempt_trace,
4964                        model_used: reported_model_used,
4965                        model_identity: bound_model_identity(
4966                            catalog_snapshot,
4967                            requested_model_id.as_deref(),
4968                            &resolved_id,
4969                        )?,
4970                        latency_ms,
4971                        time_to_first_token_ms,
4972                        usage,
4973                        provider_output_items: captured_provider_output_items,
4974                        thinking: captured_thinking,
4975                        stop_reason,
4976                        auth_fallback_from: auth_dead_lane.clone(),
4977                        local_last_resort,
4978                        fallback_from: fallback_hops.clone(),
4979                    });
4980                }
4981                Err(e) => {
4982                    if matches!(e, InferenceError::ControlledTermination) {
4983                        self.outcome_tracker
4984                            .write()
4985                            .await
4986                            .record_capability_rejection(&attempt_trace, &e.to_string());
4987                        return Err(e);
4988                    }
4989                    tracing::warn!(
4990                        model = %candidate_name,
4991                        error = %e,
4992                        remaining = candidate_queue.len(),
4993                        "model failed, trying next fallback immediately"
4994                    );
4995                    // A candidate whose CREDENTIAL was rejected is a dead lane,
4996                    // not a flaky one: retrying cannot help and the human has to
4997                    // sign in. Remember the first such lane so a later
4998                    // candidate's success can announce that it degraded off it
4999                    // instead of silently serving a different model
5000                    // (Parslee-ai/car#888).
5001                    record_auth_dead_lane(&mut auth_dead_lane, &candidate_name, &e.to_string());
5002                    record_route_credential_failure(
5003                        &mut route_credential_failure,
5004                        &candidate_name,
5005                        &e,
5006                        was_primary && has_requested_route,
5007                    );
5008                    record_fallback_from(&mut fallback_hops, &candidate_name, &e);
5009                    // Resolve every attempt exactly once. A deterministic
5010                    // request/provider capability mismatch is visible in the
5011                    // receipt ledger but must not degrade the model's generic
5012                    // health or answer-quality profile for unrelated traffic.
5013                    {
5014                        let mut tracker = self.outcome_tracker.write().await;
5015                        match &e {
5016                            InferenceError::UnsupportedMode { .. } => {
5017                                tracker.record_capability_rejection(&attempt_trace, &e.to_string())
5018                            }
5019                            // Someone's billing is not the model's fault
5020                            // (Parslee-ai/car#650).
5021                            InferenceError::ProviderAccount { .. } => {
5022                                tracker.record_account_rejection(&attempt_trace, &e.to_string())
5023                            }
5024                            // Nor is someone's missing gateway provisioning.
5025                            // Booked as an unattributed receipt for the same
5026                            // reason: the request is real and belongs in the
5027                            // ledger, but the model never ran and must not wear
5028                            // the failure (Parslee-ai/car#786).
5029                            InferenceError::GatewayUnconfigured { .. } => {
5030                                tracker.record_account_rejection(&attempt_trace, &e.to_string())
5031                            }
5032                            // A filter in front of the model refused the
5033                            // request. Recorded as a capability rejection —
5034                            // the same bucket as a deterministic mode mismatch,
5035                            // because that is what it is: this request will be
5036                            // refused every time, while the model stays healthy
5037                            // for everything else (Parslee-ai/car#796).
5038                            InferenceError::ContentRefused { .. } => {
5039                                tracker.record_capability_rejection(&attempt_trace, &e.to_string())
5040                            }
5041                            _ => tracker.record_failure(&attempt_trace, &e.to_string()),
5042                        }
5043                    }
5044                    // Circuit breaker: record failure (#25).
5045                    // 4xx errors (client errors) use longer cooldown since they indicate
5046                    // permanent incompatibility (wrong endpoint, unsupported param).
5047                    // EXCEPTION: `UnsupportedMode` is a deterministic capability
5048                    // mismatch (e.g. JsonSchema on Anthropic, video on a text-only
5049                    // provider) — see `error_counts_against_circuit_breaker`. The
5050                    // The outcome trace above is recorded as a capability
5051                    // rejection (not a profile failure), and the fallback loop
5052                    // still advances to a model that supports the mode.
5053                    if error_counts_against_circuit_breaker(&e) {
5054                        let err_str = e.to_string();
5055                        let is_client_error =
5056                            err_str.contains("API returned 4") && !err_str.contains("429");
5057                        if let Ok(mut cb) = self.adaptive_router.circuit_breakers.lock() {
5058                            // Canonical `resolved_id` — see the success path above.
5059                            cb.record_failure(&resolved_id);
5060                            // For persistent 4xx errors, lower the threshold by
5061                            // recording an extra failure to trip faster
5062                            if is_client_error {
5063                                cb.record_failure(&resolved_id);
5064                            }
5065                        }
5066                    }
5067                    // Reset backend so next model can load
5068                    #[cfg(not(all(
5069                        target_os = "macos",
5070                        target_arch = "aarch64",
5071                        not(car_skip_mlx)
5072                    )))]
5073                    {
5074                        let mut write = self.backend.write().await;
5075                        if write.remove(&resolved_id).is_some() {
5076                            self.local_admission.mark_evicted(&resolved_id);
5077                        }
5078                    }
5079                    // I4 provider failover: when the PRIMARY fails with a
5080                    // transient provider-side error (5xx/429/timeout), a
5081                    // same-provider sibling is likely down too — promote
5082                    // the first CROSS-provider fallback to the queue
5083                    // front. Permanent errors (auth, bad request) keep the
5084                    // router's original order: they're caller-shaped, not
5085                    // provider-shaped.
5086                    // An account rejection is true of every model on that
5087                    // account, so trying the rest of its candidates NEXT just
5088                    // replays the identical 401/402 — latency for nothing, and
5089                    // a pile of duplicate receipts. Send them to the back of
5090                    // the chain so another provider is tried first
5091                    // (Parslee-ai/car#650).
5092                    //
5093                    // Demoted, not dropped: soft like every other constraint on
5094                    // this path. Two credentials can share one provider label
5095                    // (per-model `api_key_env`, a multi-key pool), so a hard
5096                    // drop could remove the chain's last working option.
5097                    // An unconfigured gateway namespace is stronger than an
5098                    // account rejection: the deployment has NO upstream to
5099                    // proxy to, so every remaining alias under the prefix is
5100                    // certain to fail, not merely likely. Drop them outright
5101                    // rather than demoting — demotion is the right hedge for
5102                    // ProviderAccount, where two credentials can share one
5103                    // provider label and the chain's last working option might
5104                    // sit behind it, but here the prefix IS the condition's
5105                    // scope and nothing under it can differ (car#786).
5106                    if let InferenceError::GatewayUnconfigured { namespace, .. } = &e {
5107                        let before = candidate_queue.len();
5108                        candidate_queue.retain(|id| !id.starts_with(namespace.as_str()));
5109                        let dropped = before - candidate_queue.len();
5110                        if dropped > 0 {
5111                            tracing::info!(
5112                                %namespace,
5113                                dropped,
5114                                remaining = candidate_queue.len(),
5115                                "gateway has no upstream for this namespace; dropping its \
5116                                 remaining candidates instead of replaying the same rejection"
5117                            );
5118                        }
5119                    }
5120                    if error_ends_fallback_chain(&e) {
5121                        let dropped = candidate_queue.len();
5122                        candidate_queue.clear();
5123                        if dropped > 0 {
5124                            tracing::info!(
5125                                dropped,
5126                                "content refused for this request; ending the fallback chain \
5127                                 rather than answering with a different model"
5128                            );
5129                        }
5130                    }
5131                    if let InferenceError::ProviderAccount { provider, .. } = &e {
5132                        // Resolve the provider from the REGISTRY, not a string
5133                        // split on the id — same reasoning as the cross-provider
5134                        // promotion below (linus review #4 on I4).
5135                        let mut rest: Vec<String> = candidate_queue.iter().cloned().collect();
5136                        let demoted = routing_ext::demote_provider(provider, &mut rest, |id| {
5137                            routing_registry
5138                                .get(id)
5139                                .or_else(|| routing_registry.find_by_name(id))
5140                                .map(|s| s.provider.clone())
5141                        });
5142                        candidate_queue = rest.into();
5143                        if demoted > 0 {
5144                            tracing::info!(
5145                                %provider,
5146                                demoted,
5147                                remaining = candidate_queue.len(),
5148                                "account-level rejection; deferring this provider's \
5149                                 remaining candidates to the end of the chain"
5150                            );
5151                        }
5152                    }
5153                    if was_primary && !candidate_queue.is_empty() {
5154                        let err_str = e.to_string();
5155                        // Recover the HTTP status the remote backend baked
5156                        // into "API returned <status>: <body>" so a body
5157                        // that merely QUOTES a transient phrase (e.g. a 400
5158                        // whose message says "timeout param invalid") can't
5159                        // classify as transient (linus review #3). Typed
5160                        // error plumbing is the named follow-up.
5161                        let status = parse_api_returned_status(&err_str);
5162                        if routing_ext::is_provider_transient(status, &err_str) {
5163                            // Resolve provider from the REGISTRY, not a
5164                            // string split — slashless aliases would make
5165                            // every candidate look cross-provider (linus
5166                            // review #4).
5167                            let provider_of = |id: &str| {
5168                                routing_registry
5169                                    .get(id)
5170                                    .or_else(|| routing_registry.find_by_name(id))
5171                                    .map(|s| s.provider.clone())
5172                            };
5173                            let primary = provider_of(candidate_id).unwrap_or_default();
5174                            let queue_vec: Vec<String> = candidate_queue.iter().cloned().collect();
5175                            if let Some(cross) =
5176                                routing_ext::first_cross_provider(&primary, &queue_vec, provider_of)
5177                            {
5178                                let cross = cross.to_string();
5179                                if let Some(pos) = candidate_queue.iter().position(|m| *m == cross)
5180                                {
5181                                    if pos > 0 {
5182                                        if let Some(m) = candidate_queue.remove(pos) {
5183                                            tracing::info!(
5184                                                promoted = %m,
5185                                                "transient provider error on primary; promoting cross-provider fallback"
5186                                            );
5187                                            candidate_queue.push_front(m);
5188                                        }
5189                                    }
5190                                }
5191                            }
5192                        }
5193                    }
5194                    last_error = Some(e);
5195                }
5196            }
5197        }
5198
5199        // All models failed.
5200        let underlying = last_error.unwrap_or(InferenceError::InferenceFailed(
5201            "no models available".into(),
5202        ));
5203
5204        // A fresh install with no Parslee auth exhausts the entire
5205        // fallback chain and surfaces an opaque "no credential for
5206        // proprietary provider 'parslee'" with zero recovery guidance —
5207        // the #231 §7.1 DX failure (acute on Windows, which has no MLX
5208        // path and no bundled local model). When the exhaustion is a
5209        // missing-backend/credential case, wrap it with the two
5210        // concrete recovery paths; other errors pass through unchanged
5211        // so a genuine 500/timeout/429 isn't buried under setup advice.
5212        // Classify the exhaustion: a never-signed-in / no-backend case gets the
5213        // setup hint; an expired-credential (auth-rejection) exhaustion gets the
5214        // re-authenticate hint — otherwise a 401 from a lapsed Parslee session
5215        // surfaced verbatim as a raw HTTP status with no guidance. A genuine
5216        // 500/timeout/429 matches neither and passes through unchanged.
5217        let e = apply_route_failure_context(underlying, route_credential_failure.as_ref());
5218        // Each candidate already recorded its own failure against its
5219        // per-attempt trace inside the loop, so there is nothing to record
5220        // here — doing so was the source of the fail_count > total_calls skew.
5221        self.auto_save_outcomes().await;
5222        Err(e)
5223    }
5224
5225    /// Internal producer for [`generate_tracked_stream`]: resolves the model,
5226    /// spawns the backend generation task, and returns the resolved model id
5227    /// alongside the event receiver. The public wrapper taps this stream to
5228    /// record an outcome when it finishes.
5229    async fn generate_stream_raw(
5230        &self,
5231        req: GenerateRequest,
5232    ) -> Result<(String, tokio::sync::mpsc::Receiver<stream::StreamEvent>), InferenceError> {
5233        let routing_registry = self
5234            .request_routing_registry_snapshot(req.model.as_deref())
5235            .await;
5236        if let Some(requested) = req.model.as_deref() {
5237            if routing_registry
5238                .get(requested)
5239                .or_else(|| routing_registry.find_by_name(requested))
5240                .is_none()
5241            {
5242                return Err(InferenceError::ModelNotFound(requested.to_string()));
5243            }
5244        }
5245        let has_tools = Self::request_has_tools(&req);
5246        let has_vision = Self::request_needs_vision(&req);
5247        let (estimated_input, _, _) = self.estimated_tokens(&req, None);
5248        let estimated_footprint = estimated_input.saturating_add(req.params.max_tokens);
5249        let (estimated_cache_read, estimated_cache_write) =
5250            Self::routing_cache_estimates(&req, estimated_input);
5251        let preferred_model = self
5252            .preferred_model_for_capability(ModelCapability::Generate)
5253            .map(str::to_string);
5254        let exclude_set = self
5255            .adaptive_router
5256            .build_exclude_set(req.intent.as_ref(), &routing_registry);
5257        let unpinned_override = self
5258            .lane_pin_for(&req, &routing_registry)
5259            .or(preferred_model)
5260            .filter(|model| !Self::model_is_excluded(&exclude_set, &routing_registry, model));
5261        let decision = match req.model.clone().or(unpinned_override) {
5262            Some(m) => {
5263                let ctx_len = routing_registry
5264                    .get(&m)
5265                    .or_else(|| routing_registry.find_by_name(&m))
5266                    .map(|s| s.context_length)
5267                    .unwrap_or(0);
5268                AdaptiveRoutingDecision {
5269                    model_id: m.clone(),
5270                    model_name: m,
5271                    task: InferenceTask::Generate,
5272                    complexity: TaskComplexity::assess(&req.prompt),
5273                    reason: "explicit model".into(),
5274                    strategy: RoutingStrategy::Explicit,
5275                    predicted_quality: 0.5,
5276                    fallbacks: vec![],
5277                    context_length: ctx_len,
5278                    needs_compaction: false,
5279                    candidates: vec![],
5280                }
5281            }
5282            None => {
5283                let tracker_read = self.outcome_tracker.read().await;
5284                self.adaptive_router
5285                    .route_with(crate::adaptive_router::RouteRequest {
5286                        estimated_total_tokens: estimated_footprint,
5287                        estimated_input_tokens: estimated_input,
5288                        estimated_output_tokens: req.params.max_tokens,
5289                        estimated_cache_read_tokens: estimated_cache_read,
5290                        estimated_cache_write_tokens: estimated_cache_write,
5291                        has_tools,
5292                        has_vision,
5293                        workload: req.params.workload,
5294                        intent: req.intent.as_ref(),
5295                        ..crate::adaptive_router::RouteRequest::new(
5296                            &req.prompt,
5297                            &routing_registry,
5298                            &tracker_read,
5299                        )
5300                    })
5301            }
5302        };
5303
5304        if decision.model_id.is_empty() {
5305            let excluded_models = req
5306                .intent
5307                .as_ref()
5308                .map(|hint| hint.exclude_models.join(", "))
5309                .unwrap_or_default();
5310            return Err(InferenceError::NoEligibleModel { excluded_models });
5311        }
5312
5313        // `mut` is needed on the aarch64-macos cfg branch below;
5314        // other targets don't rebind.
5315        #[allow(unused_mut)]
5316        let mut schema = routing_registry
5317            .get(&decision.model_id)
5318            .or_else(|| routing_registry.find_by_name(&decision.model_id))
5319            .cloned();
5320
5321        // On Apple Silicon, redirect GGUF/Candle models to their MLX equivalents.
5322        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
5323        if let Some(ref s) = schema {
5324            if let Some(mlx_equiv) = routing_registry.resolve_mlx_equivalent(s) {
5325                tracing::info!(
5326                    from = %s.id, to = %mlx_equiv.id,
5327                    "redirecting GGUF model to MLX equivalent on Apple Silicon (stream)"
5328                );
5329                schema = Some(mlx_equiv.clone());
5330            }
5331        }
5332
5333        // Tool-capability guard (honest routing) — streaming parity with the
5334        // non-streaming generate path. A tools-bearing request that resolved to
5335        // a backend which can't parse tool calls (e.g. the in-process
5336        // mlx/candle path) would otherwise stream prose and silently drop the
5337        // tools. Fail clearly instead.
5338        if has_tools
5339            && schema
5340                .as_ref()
5341                .map(|s| !s.has_capability(ModelCapability::ToolUse))
5342                .unwrap_or(false)
5343        {
5344            let backend = schema
5345                .as_ref()
5346                .map(|s| if s.is_local() { "local" } else { "remote" })
5347                .unwrap_or("unknown");
5348            return Err(InferenceError::UnsupportedMode {
5349                mode: "tool_use",
5350                backend,
5351                reason: "resolved model does not support structured tool calls; configure a tool-capable model (a remote API model, or run the vllm-mlx OpenAI-compatible server)",
5352            });
5353        }
5354
5355        // Identity the tap records the outcome against (post-MLX-redirect).
5356        let resolved_model_id = schema
5357            .as_ref()
5358            .map(|s| s.id.clone())
5359            .unwrap_or_else(|| decision.model_id.clone());
5360
5361        // Default per-turn output budget from the resolved model when the
5362        // caller left it at the library default (4096) — mirrors the
5363        // non-streaming generate_tracked path so streamed long-horizon tool_use
5364        // JSON isn't truncated at 4096 either, and so an in-process model does
5365        // not silently inherit a 32768-token (== tens of minutes) budget here
5366        // after that was fixed on the non-streaming path (car#851).
5367        let mut req = req;
5368        if let Some(schema) = schema.as_ref() {
5369            req.params.max_tokens = resolved_max_tokens(req.params.max_tokens, schema);
5370        }
5371
5372        let is_remote = schema
5373            .as_ref()
5374            .map(|s| s.is_remote() || s.is_vllm_mlx())
5375            .unwrap_or(false);
5376
5377        let is_codex_cli = schema.as_ref().map(|s| s.is_codex_cli()).unwrap_or(false);
5378        let is_delegated = schema.as_ref().map(|s| s.is_delegated()).unwrap_or(false);
5379
5380        if is_codex_cli {
5381            return Err(InferenceError::UnsupportedMode {
5382                mode: "streaming",
5383                backend: "codex-cli",
5384                reason: "codex exec reports final answer items rather than token deltas; use non-streaming infer/generate_tracked",
5385            });
5386        }
5387
5388        if is_delegated {
5389            // Closes Parslee-ai/car-releases#24. The host owns the
5390            // wire format; CAR just plays back the events the runner
5391            // emits and stays in the policy/replay path.
5392            let runner = runner::current_inference_runner().ok_or_else(|| {
5393                InferenceError::InferenceFailed(
5394                    "model declares ModelSource::Delegated but no inference runner is registered \
5395                     (call set_inference_runner / registerInferenceRunner / register_inference_runner)"
5396                        .into(),
5397                )
5398            })?;
5399            let (tx, rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
5400            let emitter = runner::EventEmitter::new(tx);
5401            let request = req.clone();
5402            tokio::spawn(async move {
5403                if let Err(e) = runner.run(request, emitter).await {
5404                    tracing::warn!(error = %e, "delegated inference runner failed");
5405                }
5406            });
5407            return Ok((resolved_model_id, rx));
5408        }
5409
5410        // LOCAL_ADMISSION_BOUNDARY:stream-local-dispatch
5411        let mut local_reservation =
5412            if !is_remote || schema.as_ref().is_some_and(ModelSchema::is_vllm_mlx) {
5413                schema
5414                    .as_ref()
5415                    .filter(|schema| Self::reserve_in_outer_dispatch(schema))
5416                    .map(|schema| self.reserve_local_request(schema, estimated_footprint))
5417                    .transpose()?
5418            } else {
5419                None
5420            };
5421
5422        // On-device streaming isolated in a worker subprocess (car-releases#74)
5423        // — the streaming mirror of the non-streaming offload in
5424        // `generate_tracked_inner`. Only local models route here; a remote HTTP
5425        // stream can't abort the process, so it stays in-daemon. A mid-stream
5426        // worker death drops the sender, which the caller sees as a normal
5427        // stream end (the accumulator surfaces whatever arrived).
5428        if !is_remote && schema.as_ref().is_some_and(Self::supports_worker_offload) {
5429            if let Some(offload) = crate::offload::current_local_offload() {
5430                let mut offload_req = req.clone();
5431                offload_req.model = Some(resolved_model_id.clone());
5432                let schema_ref = schema
5433                    .as_ref()
5434                    .ok_or_else(|| InferenceError::ModelNotFound(resolved_model_id.clone()))?;
5435                let reservation = local_reservation.as_mut().ok_or_else(|| {
5436                    InferenceError::InferenceFailed(
5437                        "local worker stream missing admission reservation".into(),
5438                    )
5439                })?;
5440                if let Some(allocation_id) = offload.resident_allocation_id(&resolved_model_id) {
5441                    reservation.bind_allocation_id(&allocation_id);
5442                }
5443                let admission = self.prepare_worker_admission(schema_ref, reservation)?;
5444                let offload_stream = offload.stream_admitted(offload_req, admission).await?;
5445                Self::reconcile_worker_residency(
5446                    offload.as_ref(),
5447                    &resolved_model_id,
5448                    &offload_stream.residency,
5449                    offload_stream.retention,
5450                    reservation,
5451                )
5452                .await?;
5453                let rx = offload_stream.events;
5454                let rx = match local_reservation {
5455                    Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
5456                    None => rx,
5457                };
5458                return Ok((resolved_model_id, rx));
5459            }
5460        }
5461
5462        if is_remote {
5463            let mut candidates = vec![schema.unwrap()];
5464            let mut local_fallback_ids = Vec::new();
5465            for fallback_id in &decision.fallbacks {
5466                if let Some(fallback) = routing_registry
5467                    .get(fallback_id)
5468                    .or_else(|| routing_registry.find_by_name(fallback_id))
5469                {
5470                    if (fallback.is_remote() || fallback.is_vllm_mlx())
5471                        && (!has_tools || fallback.has_capability(ModelCapability::ToolUse))
5472                        && (!has_vision || fallback.has_capability(ModelCapability::Vision))
5473                        && !candidates
5474                            .iter()
5475                            .any(|candidate| candidate.id == fallback.id)
5476                    {
5477                        candidates.push(fallback.clone());
5478                    } else if fallback.is_local()
5479                        && !fallback.is_vllm_mlx()
5480                        && (!has_tools || fallback.has_capability(ModelCapability::ToolUse))
5481                        && (!has_vision || fallback.has_capability(ModelCapability::Vision))
5482                    {
5483                        local_fallback_ids.push(fallback.id.clone());
5484                    }
5485                }
5486            }
5487            let mut last_error = None;
5488            for candidate in candidates {
5489                // For a vllm-mlx model this starts (and health-waits) its
5490                // supervised server and rewrites the endpoint to the live port.
5491                let (candidate, mut candidate_reservation) = match self
5492                    .vllm_live_schema(candidate, local_reservation.take(), estimated_footprint)
5493                    .await
5494                {
5495                    Ok(candidate) => candidate,
5496                    Err(error) => {
5497                        last_error = Some(error);
5498                        continue;
5499                    }
5500                };
5501                self.remote_backend.register_model_keys(&candidate).await;
5502
5503                let spend_guard = self
5504                    .spend_limits
5505                    .read()
5506                    .unwrap()
5507                    .as_ref()
5508                    .and_then(|limits| limits.per_request_usd)
5509                    .map(|budget| {
5510                        let mut prompt_tokens =
5511                            routing_ext::MidStreamSpendGuard::estimate_tokens(&req.prompt)
5512                                + req
5513                                    .context
5514                                    .as_deref()
5515                                    .map(routing_ext::MidStreamSpendGuard::estimate_tokens)
5516                                    .unwrap_or(0);
5517                        prompt_tokens += media_tokens::request_media_and_history_tokens(
5518                            req.images.as_deref(),
5519                            req.messages.as_deref(),
5520                        ) as u64;
5521                        if let Some(tools) = &req.tools {
5522                            prompt_tokens += routing_ext::MidStreamSpendGuard::estimate_tokens(
5523                                &serde_json::to_string(tools).unwrap_or_default(),
5524                            );
5525                        }
5526                        let prices = candidate.cost.prices_for(prompt_tokens as usize);
5527                        let input_price = prices
5528                            .input_per_mtok
5529                            .map(|c| c / 1_000_000.0)
5530                            .unwrap_or(0.0);
5531                        let output_price = prices
5532                            .output_per_mtok
5533                            .map(|c| c / 1_000_000.0)
5534                            .unwrap_or(0.0);
5535                        routing_ext::MidStreamSpendGuard::new(
5536                            Some(budget),
5537                            prompt_tokens as f64 * input_price,
5538                            input_price,
5539                            output_price,
5540                        )
5541                    });
5542
5543                match self
5544                    .remote_backend
5545                    .generate_stream(
5546                        &candidate,
5547                        &req.prompt,
5548                        req.messages.as_deref(),
5549                        req.context.as_deref(),
5550                        req.params.temperature,
5551                        req.params.max_tokens,
5552                        req.tools.as_deref(),
5553                        req.images.as_deref(),
5554                        req.params.tool_choice.as_deref(),
5555                        req.params.parallel_tool_calls,
5556                        req.response_format.as_ref(),
5557                        spend_guard,
5558                    )
5559                    .await
5560                {
5561                    Ok(receiver) => {
5562                        let receiver = Self::hold_optional_reservation_for_stream(
5563                            receiver,
5564                            candidate_reservation.take(),
5565                        );
5566                        return Ok((candidate.id, receiver));
5567                    }
5568                    Err(error) => {
5569                        tracing::warn!(
5570                            model = %candidate.id,
5571                            %error,
5572                            "remote stream setup failed; trying routed fallback"
5573                        );
5574                        last_error = Some(error);
5575                    }
5576                }
5577            }
5578
5579            // Remote-primary streaming must retain compatible local candidates
5580            // and re-enter the ordinary dispatcher for them. That preserves
5581            // offload/FoundationModels/native behavior instead of duplicating
5582            // a partial local backend path in this branch.
5583            if !req.params.strict_model {
5584                if let Some(local) = self.first_installed_local_model(has_tools) {
5585                    let schema = routing_registry
5586                        .get(&local)
5587                        .or_else(|| routing_registry.find_by_name(&local));
5588                    let supports_request = schema.is_some_and(|schema| {
5589                        !has_vision || schema.has_capability(ModelCapability::Vision)
5590                    });
5591                    if supports_request
5592                        && !local_fallback_ids.iter().any(|candidate| {
5593                            routing_registry
5594                                .get(candidate)
5595                                .or_else(|| routing_registry.find_by_name(candidate))
5596                                .is_some_and(|schema| schema.id == local || schema.name == local)
5597                        })
5598                    {
5599                        local_fallback_ids.push(local);
5600                    }
5601                }
5602                for local_id in local_fallback_ids {
5603                    let mut fallback_req = req.clone();
5604                    fallback_req.model = Some(local_id);
5605                    fallback_req.params.strict_model = true;
5606                    match Box::pin(self.generate_stream_raw(fallback_req)).await {
5607                        Ok(stream) => return Ok(stream),
5608                        Err(error) => {
5609                            tracing::warn!(%error, "local streaming fallback failed");
5610                            last_error = Some(error);
5611                        }
5612                    }
5613                }
5614            }
5615
5616            Err(last_error.unwrap_or_else(|| {
5617                InferenceError::InferenceFailed(
5618                    "no compatible remote streaming model available".to_string(),
5619                )
5620            }))
5621        } else {
5622            let schema =
5623                schema.ok_or_else(|| InferenceError::ModelNotFound(decision.model_id.clone()))?;
5624            let (tx, rx) = tokio::sync::mpsc::channel(64);
5625
5626            // FoundationModels streaming dispatch — reachable on the
5627            // Apple-aarch64 targets where build.rs compiles the shim
5628            // (macOS, iOS device, iOS simulator on Apple Silicon).
5629            // Split out from the MLX block below because MLX is
5630            // macOS-only — `mlx-rs` can't cross-compile for iOS.
5631            #[cfg(any(
5632                all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)),
5633                all(target_os = "ios", target_arch = "aarch64")
5634            ))]
5635            {
5636                if schema.is_foundation_models() {
5637                    // Same text-only boundary as the non-streaming FM
5638                    // branch: multimodal blocks error out instead of
5639                    // being silently dropped by the text-only bridge.
5640                    if Self::request_has_video(&req)
5641                        || Self::request_has_audio(&req)
5642                        || req.images.as_ref().is_some_and(|imgs| !imgs.is_empty())
5643                    {
5644                        return Err(InferenceError::UnsupportedMode {
5645                            mode: "multimodal-content",
5646                            backend: "foundation-models",
5647                            reason: "the FoundationModels bridge currently exposes text-only \
5648                                 generation — route image/audio/video to a remote VL model",
5649                        });
5650                    }
5651                    // Tool-enabled streaming: FM tool capture is a
5652                    // blocking round-trip (the framework invokes the
5653                    // capture tool mid-turn), so run the blocking
5654                    // bridge and emit the outcome as one TextDelta +
5655                    // Done{tool_calls} — same events, coarser grain.
5656                    if let Some(tools_defs) = req.tools.clone().filter(|t| !t.is_empty()) {
5657                        let prompt = req.prompt.clone();
5658                        let instructions = req.context.clone();
5659                        let max_tokens = req.params.max_tokens as u32;
5660                        let temperature = req.params.temperature;
5661                        tokio::task::spawn_blocking(move || {
5662                            match crate::backend::foundation_models::generate_with_tools(
5663                                &prompt,
5664                                instructions.as_deref(),
5665                                &tools_defs,
5666                                max_tokens,
5667                                temperature as f32,
5668                            ) {
5669                                Ok((text, tool_calls)) => {
5670                                    if !text.is_empty() {
5671                                        let _ = tx.blocking_send(stream::StreamEvent::TextDelta(
5672                                            text.clone(),
5673                                        ));
5674                                    }
5675                                    let _ = tx.blocking_send(stream::StreamEvent::Done {
5676                                        text,
5677                                        tool_calls,
5678                                    });
5679                                }
5680                                Err(e) => {
5681                                    // Match the MLX streaming error
5682                                    // convention: log and drop the
5683                                    // sender so the channel closes
5684                                    // without a Done event.
5685                                    tracing::warn!(
5686                                        error = %e,
5687                                        "FoundationModels tool-enabled stream failed"
5688                                    );
5689                                }
5690                            }
5691                        });
5692                        return Ok((resolved_model_id, rx));
5693                    }
5694                    let prompt = req.prompt.clone();
5695                    let instructions = req.context.clone();
5696                    let max_tokens = req.params.max_tokens as u32;
5697                    let temperature = req.params.temperature;
5698                    let tx_clone = tx.clone();
5699                    tokio::task::spawn_blocking(move || {
5700                        // Share the accumulator between the streaming
5701                        // callback and the post-stream Done event so
5702                        // the FoundationModels path matches Candle/MLX
5703                        // shape — `Done.text` is the full assembled
5704                        // generation, not an empty sentinel that
5705                        // forces consumers to reassemble.
5706                        let accum = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
5707                        let accum_cb = accum.clone();
5708                        let cb = crate::backend::foundation_models::StreamCallback::new(
5709                            move |delta: &str| {
5710                                if let Ok(mut g) = accum_cb.lock() {
5711                                    g.push_str(delta);
5712                                }
5713                                tx_clone
5714                                    .blocking_send(stream::StreamEvent::TextDelta(
5715                                        delta.to_string(),
5716                                    ))
5717                                    .is_ok()
5718                            },
5719                        );
5720                        let result = crate::backend::foundation_models::stream(
5721                            &prompt,
5722                            instructions.as_deref(),
5723                            max_tokens,
5724                            temperature as f32,
5725                            cb,
5726                        );
5727                        let final_text = accum.lock().map(|g| g.clone()).unwrap_or_default();
5728                        let _ = tx.blocking_send(stream::StreamEvent::Done {
5729                            text: final_text,
5730                            tool_calls: vec![],
5731                        });
5732                        result
5733                    });
5734                    return Ok((resolved_model_id, rx));
5735                }
5736            }
5737
5738            // MLX streaming — macOS-aarch64 only.
5739            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
5740            {
5741                // On Apple Silicon macOS, all local models must go
5742                // through MLX.
5743                if !schema.is_mlx() {
5744                    return Err(InferenceError::InferenceFailed(format!(
5745                        "model '{}' has no MLX equivalent; Candle backend disabled on Apple Silicon",
5746                        schema.id
5747                    )));
5748                }
5749                let (backend, _retention) = self
5750                    .ensure_mlx_backend(
5751                        &schema,
5752                        local_reservation
5753                            .as_mut()
5754                            .expect("local stream has admission reservation"),
5755                    )
5756                    .await?;
5757                let model_id = schema.id.clone();
5758                let cache = Arc::clone(&self.mlx_backends);
5759                // Serialize on the shared Metal device (see `mlx_device_lock`)
5760                // before the blocking eval, mirroring the media paths: acquire the
5761                // owned guard here and move it INTO the blocking closure so it is
5762                // held for the whole stream and survives RPC-deadline abandonment.
5763                // Without it a concurrent embed/other-model MLX eval (e.g. memory
5764                // consolidation) can wedge the one Metal device.
5765                let device_guard = Self::mlx_device_lock().lock_owned().await;
5766                // MLX ops are blocking (GPU-bound) and `MutexGuard<MlxBackend>`
5767                // isn't `Send`, so run the whole generation on a blocking
5768                // worker. `tx.blocking_send` bridges tokens back to the
5769                // async stream consumer without holding the guard across
5770                // an `.await`.
5771                tokio::task::spawn_blocking(move || {
5772                    let _device_guard = device_guard;
5773                    let _ = Self::stream_local_mlx(backend, cache, model_id, req, tx);
5774                });
5775                let rx = match local_reservation {
5776                    Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
5777                    None => rx,
5778                };
5779                Ok((resolved_model_id, rx))
5780            }
5781
5782            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
5783            {
5784                self.ensure_backend(
5785                    &schema,
5786                    local_reservation
5787                        .as_mut()
5788                        .expect("local stream has admission reservation"),
5789                )
5790                .await?;
5791                let backend = self.backend.clone();
5792                let model_id = schema.id.clone();
5793                tokio::spawn(async move {
5794                    let _ = Self::stream_local_candle(backend, model_id, req, tx).await;
5795                });
5796                let rx = match local_reservation {
5797                    Some(reservation) => Self::hold_local_reservation_for_stream(rx, reservation),
5798                    None => rx,
5799                };
5800                Ok((resolved_model_id, rx))
5801            }
5802        }
5803    }
5804
5805    /// Stream a generation and record an outcome when it finishes.
5806    ///
5807    /// Wraps [`generate_stream_raw`](crate::InferenceEngine::generate_stream_raw) with a forwarding "tap" task: it
5808    /// accumulates the event stream (via [`stream::StreamAccumulator`]),
5809    /// forwards every event to the caller unchanged, and on completion books a
5810    /// success/failure against the model's profile — the same outcome telemetry
5811    /// the non-streaming `generate_tracked` path records. Without this, every
5812    /// streamed inference (voice/realtime, the daemon's `infer` stream) was
5813    /// invisible to model-health scoring. Cancellation propagates: if the
5814    /// caller drops the returned receiver, the tap stops forwarding, drops the
5815    /// producer receiver, and the backend task observes the closed channel.
5816    ///
5817    /// Returns a [`TrackedStream`] carrying the resolved model + this call's
5818    /// `trace_id` alongside the event receiver. The trace_id is known up front
5819    /// (minted by `record_start` before the first token), so a caller can score
5820    /// the finished turn against the same trace the tap will resolve — the
5821    /// streaming counterpart of the non-streaming path's `InferenceResult`
5822    /// `{trace_id, model_used}`, which the conversation-outcome signal needs.
5823    ///
5824    /// The `events` receiver yields `StreamEvent` variants (`TextDelta`,
5825    /// `ToolCallStart`, `ToolCallDelta`, `Usage`, `StopReason`,
5826    /// `ProviderOutputItem`, `Error`, `Done`); use a
5827    /// [`stream::StreamAccumulator`] to collect them into a final result. Local
5828    /// backends (MLX, Candle) emit true incremental `TextDelta`s per token,
5829    /// enabling token-by-token UI, overlapping TTS, and early cancellation. The
5830    /// channel buffers 64 events so burst tokens don't block generation.
5831    ///
5832    /// ## Example: voice app integration
5833    ///
5834    /// ```rust,ignore
5835    /// let mut handle = engine.generate_tracked_stream(req).await?;
5836    /// let mut text_buf = String::new();
5837    /// while let Some(event) = handle.events.recv().await {
5838    ///     match event {
5839    ///         StreamEvent::TextDelta(delta) => {
5840    ///             text_buf.push_str(&delta);
5841    ///             // Feed text_buf to TTS when a sentence boundary is reached
5842    ///         }
5843    ///         StreamEvent::Done { text, .. } => break,
5844    ///         _ => {}
5845    ///     }
5846    /// }
5847    /// // handle.trace_id / handle.model_used identify the turn for scoring.
5848    /// ```
5849    pub async fn generate_tracked_stream(
5850        &self,
5851        req: GenerateRequest,
5852    ) -> Result<TrackedStream, InferenceError> {
5853        // Pre-call input estimate (used when the provider reports no usage,
5854        // e.g. local backends). Computed before `req` is moved into the raw
5855        // producer.
5856        let (estimated_input, _, _) = self.estimated_tokens(&req, None);
5857        let start = Instant::now();
5858
5859        // Setup/routing errors (unknown model, no runner) propagate unchanged
5860        // and are not booked as model failures — same as the non-streaming
5861        // path, which only records once a candidate actually runs.
5862        let (model_id, mut producer_rx) = self.generate_stream_raw(req).await?;
5863
5864        let trace = {
5865            let mut t = self.outcome_tracker.write().await;
5866            t.record_start(&model_id, InferenceTask::Generate, "stream")
5867        };
5868        // Surface trace_id + model to the caller. Cloned before the tap task
5869        // moves `trace` in to resolve the outcome on completion; `model_id` is
5870        // unused after `record_start`, so it moves straight into the handle.
5871        let trace_for_return = trace.clone();
5872
5873        let (out_tx, out_rx) = tokio::sync::mpsc::channel::<stream::StreamEvent>(64);
5874        let tracker = Arc::clone(&self.outcome_tracker);
5875        tokio::spawn(async move {
5876            let mut acc = stream::StreamAccumulator::default();
5877            let mut stream_error: Option<String> = None;
5878            let mut saw_done = false;
5879            let mut receiver_abandoned = false;
5880            while let Some(evt) = producer_rx.recv().await {
5881                if let stream::StreamEvent::Error(message) = &evt {
5882                    stream_error = Some(message.clone());
5883                }
5884                if matches!(evt, stream::StreamEvent::Done { .. }) {
5885                    saw_done = true;
5886                }
5887                acc.push(&evt);
5888                if out_tx.send(evt).await.is_err() {
5889                    receiver_abandoned = true;
5890                    break;
5891                }
5892            }
5893            if stream_error.is_none() && !saw_done && !receiver_abandoned {
5894                let error = "stream ended without positive provider completion".to_string();
5895                stream_error = Some(error.clone());
5896                let _ = out_tx.send(stream::StreamEvent::Error(error)).await;
5897            } else if stream_error.is_none() && receiver_abandoned {
5898                stream_error = Some("stream receiver was abandoned before completion".to_string());
5899            }
5900            let (text, tool_calls, usage, _stop) = acc.finish_with_usage();
5901            let latency_ms = start.elapsed().as_millis() as u64;
5902            let input_tokens = usage
5903                .as_ref()
5904                .map(|u| u.prompt_tokens as usize)
5905                .unwrap_or(estimated_input);
5906            // Output token count: provider usage if present, else word count.
5907            // A tool-only response (no text) is still a success, so floor the
5908            // count at 1 when tool calls were produced — record_complete gates
5909            // its mechanical-success credit on output_tokens > 0.
5910            let mut output_tokens = usage
5911                .as_ref()
5912                .map(|u| u.completion_tokens as usize)
5913                .unwrap_or_else(|| text.split_whitespace().count());
5914            if output_tokens == 0 && !tool_calls.is_empty() {
5915                output_tokens = 1;
5916            }
5917            // Cache split — currently always 0 on the streaming path because the
5918            // accumulator does not yet decode Anthropic `message_start` cache
5919            // deltas; threaded anyway so this path prices correctly the moment
5920            // it does.
5921            let (cache_read, cache_creation) = usage
5922                .as_ref()
5923                .map(|u| {
5924                    (
5925                        u.cache_read_input_tokens as usize,
5926                        u.cache_creation_input_tokens as usize,
5927                    )
5928                })
5929                .unwrap_or((0, 0));
5930            let mut t = tracker.write().await;
5931            if let Some(error) = stream_error {
5932                t.record_failure(&trace, &error);
5933            } else {
5934                t.record_complete_cached(
5935                    &trace,
5936                    latency_ms,
5937                    input_tokens,
5938                    output_tokens,
5939                    cache_read,
5940                    cache_creation,
5941                );
5942            }
5943        });
5944
5945        Ok(TrackedStream {
5946            model_used: model_id,
5947            trace_id: trace_for_return,
5948            events: out_rx,
5949        })
5950    }
5951
5952    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
5953    async fn stream_local_candle(
5954        backend_lock: Arc<RwLock<std::collections::HashMap<String, CandleBackend>>>,
5955        model_id: String,
5956        req: GenerateRequest,
5957        tx: tokio::sync::mpsc::Sender<stream::StreamEvent>,
5958    ) -> Result<(), InferenceError> {
5959        let mut write = backend_lock.write().await;
5960        let backend = write.get_mut(&model_id).ok_or_else(|| {
5961            InferenceError::InferenceFailed(format!("backend not initialized for {model_id}"))
5962        })?;
5963        backend.clear_kv_cache();
5964
5965        let formatted = tasks::generate::render_chat_prompt(&req);
5966        let tokens = backend.encode(&formatted)?;
5967        let eos = backend.eos_token_id();
5968        let eos_alt = backend.token_id("<|im_end|>");
5969        let params = &req.params;
5970
5971        if tokens.is_empty() {
5972            let _ = tx
5973                .send(stream::StreamEvent::Done {
5974                    text: String::new(),
5975                    tool_calls: vec![],
5976                })
5977                .await;
5978            return Ok(());
5979        }
5980
5981        let max_ctx = backend.context_length().unwrap_or(32768);
5982        let headroom = params.max_tokens.min(max_ctx / 4);
5983        let max_prompt = max_ctx.saturating_sub(headroom);
5984        let tokens = if tokens.len() > max_prompt {
5985            tokens[tokens.len() - max_prompt..].to_vec()
5986        } else {
5987            tokens
5988        };
5989
5990        let mut generated = Vec::new();
5991        let logits = backend.forward(&tokens, 0)?;
5992        let mut next_token = tasks::generate::sample_token(&logits, params)?;
5993
5994        for _ in 0..params.max_tokens {
5995            if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
5996                break;
5997            }
5998
5999            generated.push(next_token);
6000            let delta = backend.decode(&[next_token])?;
6001            if !delta.is_empty()
6002                && tx
6003                    .send(stream::StreamEvent::TextDelta(delta))
6004                    .await
6005                    .is_err()
6006            {
6007                return Ok(());
6008            }
6009
6010            if !params.stop.is_empty() {
6011                let text_so_far = backend.decode(&generated)?;
6012                if params.stop.iter().any(|s| text_so_far.contains(s)) {
6013                    break;
6014                }
6015            }
6016
6017            let pos = tokens.len() + generated.len() - 1;
6018            let logits = backend.forward(&[next_token], pos)?;
6019            next_token = tasks::generate::sample_token(&logits, params)?;
6020        }
6021
6022        let trimmed = tasks::generate::truncate_at_stop(&backend.decode(&generated)?, &params.stop);
6023        let text = tasks::generate::strip_thinking(&trimmed, params.thinking);
6024        // Real counts, same as the MLX stream and the non-streaming candle
6025        // path — see the note in `stream_local_mlx` (Parslee-ai/car#795).
6026        // `tokens.len()` is post-truncation: what the model actually saw.
6027        let _ = tx
6028            .send(stream::StreamEvent::Usage {
6029                input_tokens: tokens.len() as u64,
6030                output_tokens: generated.len() as u64,
6031                cache_read_input_tokens: 0,
6032                cache_creation_input_tokens: 0,
6033            })
6034            .await;
6035        let _ = tx
6036            .send(stream::StreamEvent::Done {
6037                text,
6038                tool_calls: vec![],
6039            })
6040            .await;
6041        Ok(())
6042    }
6043
6044    /// Blocking streaming generator — runs on a `spawn_blocking` worker
6045    /// so the sync MLX mutex guard isn't held across an async `.await`.
6046    /// Uses `tx.blocking_send` to push tokens back to the caller.
6047    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6048    fn stream_local_mlx(
6049        handle: backend_cache::CachedBackend<backend::MlxBackend>,
6050        cache: Arc<backend_cache::BackendCache<backend::MlxBackend>>,
6051        model_id: String,
6052        req: GenerateRequest,
6053        tx: tokio::sync::mpsc::Sender<stream::StreamEvent>,
6054    ) -> Result<(), InferenceError> {
6055        let mut guard = handle.lock().map_err(|_| {
6056            InferenceError::InferenceFailed(format!("MLX backend mutex poisoned for {model_id}"))
6057        })?;
6058        let backend: &mut backend::MlxBackend = &mut guard;
6059        backend.clear_kv_cache();
6060
6061        let formatted = tasks::generate::render_chat_prompt(&req);
6062        let tokens = backend.encode(&formatted)?;
6063        let eos = backend.eos_token_id();
6064        let eos_alt = backend.token_id("<|im_end|>");
6065        let params = &req.params;
6066
6067        if tokens.is_empty() {
6068            let _ = tx.blocking_send(stream::StreamEvent::Done {
6069                text: String::new(),
6070                tool_calls: vec![],
6071            });
6072            return Ok(());
6073        }
6074
6075        let max_ctx = backend.context_length();
6076        let headroom = params.max_tokens.min(max_ctx / 4);
6077        let max_prompt = max_ctx.saturating_sub(headroom);
6078        let tokens = if tokens.len() > max_prompt {
6079            tokens[tokens.len() - max_prompt..].to_vec()
6080        } else {
6081            tokens
6082        };
6083
6084        let mut generated = Vec::new();
6085
6086        // Same wall-clock ceiling and progress reporting the non-streaming loop
6087        // got in car#851. A streamed decode at least emits tokens as it goes,
6088        // so a client can tell it is alive — but it is reached by the daemon's
6089        // `infer_stream` RPC and by voice, and before this it had no bound at
6090        // all beyond `max_tokens`.
6091        let started = std::time::Instant::now();
6092        let timeout = local_decode_timeout();
6093        let heartbeat = std::time::Duration::from_secs(LOCAL_DECODE_HEARTBEAT_SECS);
6094        let mut last_heartbeat = std::time::Duration::ZERO;
6095        tracing::info!(
6096            prompt_tokens = tokens.len(),
6097            max_tokens = params.max_tokens,
6098            timeout_secs = timeout.map(|t| t.as_secs()),
6099            "local stream prefill starting"
6100        );
6101
6102        // Wrap MLX forward calls to catch panics. On panic, drop this
6103        // backend from the cache — its KV cache may be in an
6104        // indeterminate state and subsequent callers would inherit it.
6105        // Outstanding handles continue to work until their Arc drops.
6106        let logits = match Self::catch_mlx("stream prefill", || backend.forward(&tokens, 0)) {
6107            Ok(v) => v,
6108            Err(e) => {
6109                cache.invalidate(&model_id);
6110                return Err(e);
6111            }
6112        };
6113        let mut next_token = Self::sample_from_logits(&logits, params)?;
6114
6115        for _ in 0..params.max_tokens {
6116            if (eos == Some(next_token)) || (eos_alt == Some(next_token)) {
6117                break;
6118            }
6119
6120            generated.push(next_token);
6121            let delta = backend.decode(&[next_token])?;
6122            if !delta.is_empty()
6123                && tx
6124                    .blocking_send(stream::StreamEvent::TextDelta(delta))
6125                    .is_err()
6126            {
6127                return Ok(());
6128            }
6129
6130            if !params.stop.is_empty() {
6131                let text_so_far = backend.decode(&generated)?;
6132                if params.stop.iter().any(|s| text_so_far.contains(s)) {
6133                    break;
6134                }
6135            }
6136
6137            // Deadline AFTER the push/send and the stop check, matching
6138            // `drive_generation_with_timeout` exactly. Checking it earlier drops
6139            // the token already sampled — the streamed and non-streamed partials
6140            // would then differ by one token for the same cut-off.
6141            let elapsed = started.elapsed();
6142            if deadline_exceeded(elapsed, timeout) {
6143                tracing::warn!(
6144                    elapsed_secs = elapsed.as_secs_f64(),
6145                    timeout_secs = timeout.map(|t| t.as_secs()),
6146                    completion_tokens = generated.len(),
6147                    max_tokens = params.max_tokens,
6148                    "local stream hit its wall-clock ceiling and was cut short; \
6149                     returning what was streamed so far. Raise or disable it with \
6150                     CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables)."
6151                );
6152                // Without this the consumer cannot tell a cut-off stream from a
6153                // finished one: `StreamAccumulator::was_truncated()` keys off
6154                // the stop reason, and the local streaming path never emitted
6155                // one. The daemon's `infer_stream` RPC and voice both read it.
6156                let _ = tx.blocking_send(stream::StreamEvent::StopReason(
6157                    LOCAL_DECODE_TIMEOUT_STOP_REASON.to_string(),
6158                ));
6159                break;
6160            }
6161            if heartbeat_due(elapsed, last_heartbeat, heartbeat) {
6162                last_heartbeat = elapsed;
6163                tracing::info!(
6164                    completion_tokens = generated.len(),
6165                    max_tokens = params.max_tokens,
6166                    elapsed_secs = elapsed.as_secs_f64(),
6167                    tokens_per_sec =
6168                        generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON),
6169                    "local stream in progress"
6170                );
6171            }
6172
6173            let pos = tokens.len() + generated.len() - 1;
6174            let logits =
6175                match Self::catch_mlx("stream forward", || backend.forward(&[next_token], pos)) {
6176                    Ok(v) => v,
6177                    Err(e) => {
6178                        cache.invalidate(&model_id);
6179                        return Err(e);
6180                    }
6181                };
6182            next_token = Self::sample_from_logits(&logits, params)?;
6183        }
6184
6185        let trimmed = tasks::generate::truncate_at_stop(&backend.decode(&generated)?, &params.stop);
6186        let text = tasks::generate::strip_thinking(&trimmed, params.thinking);
6187        // Report the counts this loop already knows, the way a remote provider
6188        // reports its own (Parslee-ai/car#795). Without this event the
6189        // accumulator's `saw_usage` stays false and EVERY streamed local
6190        // generation ends with `usage: null`, while the non-streaming MLX path
6191        // right next to it returns real numbers — so the same model reported
6192        // tokens or didn't purely on whether the caller streamed.
6193        // `tokens.len()` is post-truncation: what the model actually saw.
6194        let _ = tx.blocking_send(stream::StreamEvent::Usage {
6195            input_tokens: tokens.len() as u64,
6196            output_tokens: generated.len() as u64,
6197            // In-process inference has no remote prompt cache.
6198            cache_read_input_tokens: 0,
6199            cache_creation_input_tokens: 0,
6200        });
6201        let _ = tx.blocking_send(stream::StreamEvent::Done {
6202            text,
6203            tool_calls: vec![],
6204        });
6205        Ok(())
6206    }
6207
6208    /// Route a prompt using the adaptive router without executing inference.
6209    pub async fn route_context_snapshot(
6210        &self,
6211        prompt: &str,
6212        workload: RoutingWorkload,
6213        has_tools: bool,
6214        has_vision: bool,
6215    ) -> AdaptiveRoutingDecision {
6216        let routing_registry = self.catalog_registry_snapshot();
6217        let tracker = self.outcome_tracker.read().await;
6218        self.adaptive_router.route_context_aware(
6219            prompt,
6220            0,
6221            &routing_registry,
6222            &tracker,
6223            has_tools,
6224            has_vision,
6225            workload,
6226        )
6227    }
6228
6229    /// Generate text from a prompt (legacy API, no outcome tracking).
6230    /// When `req.model` is None, uses intelligent routing based on prompt complexity.
6231    pub async fn generate(&self, req: GenerateRequest) -> Result<String, InferenceError> {
6232        Ok(self.generate_tracked(req).await?.text)
6233    }
6234
6235    /// Encode `text` via the named model's tokenizer. Returns raw token IDs
6236    /// without any chat-template wrapping or BOS-prepending — pair with
6237    /// [`Self::detokenize`] for the round-trip property
6238    /// `detokenize(model, tokenize(model, s)) == s` for any UTF-8 `s`.
6239    ///
6240    /// Only local models have a tokenizer the runtime can call directly
6241    /// (Candle/GGUF on Linux/Windows, MLX on Apple Silicon). For remote
6242    /// models the call returns
6243    /// [`InferenceError::UnsupportedMode`] — provider tokenizer endpoints
6244    /// vary too widely to be portable here, and bundling tiktoken-style
6245    /// tables would lock the registry to a fixed set of providers.
6246    pub async fn tokenize(&self, model: &str, text: &str) -> Result<Vec<u32>, InferenceError> {
6247        self.assert_local_for_tokenize(model)?;
6248        let admission_schema = self
6249            .unified_registry
6250            .get(model)
6251            .or_else(|| self.unified_registry.find_by_name(model))
6252            .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?
6253            .clone();
6254        // LOCAL_ADMISSION_BOUNDARY:tokenizer-encode
6255        let mut reservation =
6256            self.reserve_local_request(&admission_schema, text.len().div_ceil(4))?;
6257
6258        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6259        {
6260            let schema = admission_schema;
6261            let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6262            let guard = handle.lock().map_err(|_| {
6263                InferenceError::InferenceFailed(format!(
6264                    "MLX backend mutex poisoned for {}",
6265                    schema.id
6266                ))
6267            })?;
6268            let tokens = guard.tokenize_raw(text)?;
6269            Ok(tokens)
6270        }
6271
6272        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6273        {
6274            self.ensure_backend(&admission_schema, &mut reservation)
6275                .await?;
6276            let read = self.backend.read().await;
6277            let backend = read.get(&admission_schema.id).ok_or_else(|| {
6278                InferenceError::InferenceFailed(
6279                    "candle backend missing after ensure_backend".to_string(),
6280                )
6281            })?;
6282            let tokens = backend.tokenize_raw(text)?;
6283            Ok(tokens)
6284        }
6285    }
6286
6287    /// Inverse of [`Self::tokenize`]: decode token IDs back to text.
6288    pub async fn detokenize(&self, model: &str, tokens: &[u32]) -> Result<String, InferenceError> {
6289        self.assert_local_for_tokenize(model)?;
6290        let admission_schema = self
6291            .unified_registry
6292            .get(model)
6293            .or_else(|| self.unified_registry.find_by_name(model))
6294            .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?
6295            .clone();
6296        // LOCAL_ADMISSION_BOUNDARY:tokenizer-decode
6297        let mut reservation = self.reserve_local_request(&admission_schema, tokens.len())?;
6298
6299        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6300        {
6301            let schema = admission_schema;
6302            let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6303            let guard = handle.lock().map_err(|_| {
6304                InferenceError::InferenceFailed(format!(
6305                    "MLX backend mutex poisoned for {}",
6306                    schema.id
6307                ))
6308            })?;
6309            let text = guard.detokenize_raw(tokens)?;
6310            Ok(text)
6311        }
6312
6313        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6314        {
6315            self.ensure_backend(&admission_schema, &mut reservation)
6316                .await?;
6317            let read = self.backend.read().await;
6318            let backend = read.get(&admission_schema.id).ok_or_else(|| {
6319                InferenceError::InferenceFailed(
6320                    "candle backend missing after ensure_backend".to_string(),
6321                )
6322            })?;
6323            let text = backend.detokenize_raw(tokens)?;
6324            Ok(text)
6325        }
6326    }
6327
6328    /// Common pre-flight for [`Self::tokenize`] / [`Self::detokenize`]: bail
6329    /// early on remote models with the same `UnsupportedMode` taxonomy used
6330    /// elsewhere on the engine surface.
6331    fn assert_local_for_tokenize(&self, model: &str) -> Result<(), InferenceError> {
6332        if let Some(schema) = self
6333            .unified_registry
6334            .get(model)
6335            .or_else(|| self.unified_registry.find_by_name(model))
6336        {
6337            if !schema.is_local() {
6338                return Err(InferenceError::UnsupportedMode {
6339                    mode: "tokenize/detokenize",
6340                    backend: "remote",
6341                    reason: "remote provider tokenizer is not exposed by the runtime; \
6342                         use a local model (Qwen3 GGUF / MLX) for tokenizer-correctness checks",
6343                });
6344            }
6345        }
6346        // Unknown model name: let the load step surface ModelNotFound below.
6347        Ok(())
6348    }
6349
6350    /// Wrap an MLX FFI call with catch_unwind to catch Rust panics at the boundary.
6351    /// NOTE: This catches Rust panics only, NOT C++ exceptions from Metal/MLX.
6352    /// True C++ exceptions will still abort the process — that requires an upstream
6353    /// fix in mlx-rs to catch C++ exceptions before they cross the FFI boundary.
6354    /// On panic, callers MUST remove the backend from the map — it is poisoned.
6355    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6356    fn catch_mlx<F, T>(context: &str, f: F) -> Result<T, InferenceError>
6357    where
6358        F: FnOnce() -> Result<T, InferenceError>,
6359    {
6360        std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|e| {
6361            InferenceError::InferenceFailed(format!("MLX panicked during {context}: {e:?}"))
6362        })?
6363    }
6364
6365    /// Architecture-neutral decode loop over any [`TextDecoder`] (macOS MLX
6366    /// backends — Qwen3, Gemma 4, …). Owns prompt
6367    /// encoding, context-window truncation, prefill, the sampling/stop loop,
6368    /// TTFT timing, and the FFI-boundary panic-catch — everything that used to
6369    /// be inlined per-backend in `generate_mlx`. The eos convention is the
6370    /// backend's (`eos_ids`), so this loop knows nothing about `<|im_end|>` or
6371    /// any other family's turn-enders. Cache invalidation on a caught panic is
6372    /// the caller's job (it owns the lock guard) — signaled via
6373    /// [`DriveError::BackendCorrupted`].
6374    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6375    fn drive_generation(
6376        backend: &mut dyn backend::local::TextDecoder,
6377        prompt: &str,
6378        params: &GenerateParams,
6379    ) -> Result<backend::local::LocalGeneration, backend::local::DriveError> {
6380        Self::drive_generation_with_timeout(backend, prompt, params, local_decode_timeout())
6381    }
6382
6383    /// `drive_generation`(Self::drive_generation) with the wall-clock ceiling
6384    /// passed in rather than read from the environment, so the deadline is
6385    /// testable without mutating process-global state from a test thread.
6386    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6387    fn drive_generation_with_timeout(
6388        backend: &mut dyn backend::local::TextDecoder,
6389        prompt: &str,
6390        params: &GenerateParams,
6391        timeout: Option<std::time::Duration>,
6392    ) -> Result<backend::local::LocalGeneration, backend::local::DriveError> {
6393        use backend::local::{DriveError, LocalGeneration};
6394
6395        let start = std::time::Instant::now();
6396
6397        let tokens = backend.encode(prompt).map_err(DriveError::Recoverable)?;
6398        let eos_ids = backend.eos_ids();
6399
6400        if tokens.is_empty() {
6401            backend.clear_kv_cache();
6402            return Ok(LocalGeneration {
6403                text: String::new(),
6404                ttft_ms: None,
6405                stop_reason: None,
6406                prompt_tokens: 0,
6407                completion_tokens: 0,
6408            });
6409        }
6410
6411        // Truncate to context length, reserving headroom for the response.
6412        let max_ctx = backend.context_length();
6413        let headroom = params.max_tokens.min(max_ctx / 4);
6414        let max_prompt = max_ctx.saturating_sub(headroom);
6415        let tokens = if tokens.len() > max_prompt {
6416            tokens[tokens.len() - max_prompt..].to_vec()
6417        } else {
6418            tokens
6419        };
6420
6421        let mut generated = Vec::new();
6422
6423        // Announce BEFORE prefill, not after. Prefill is a single MLX forward
6424        // over the whole prompt and nothing can interrupt it — on a large model
6425        // with an agent's tools block it is tens of seconds on its own. Logging
6426        // only after it would leave exactly the window car#851 was reported for
6427        // still silent, and would let a prefill stall masquerade as a slow
6428        // decode.
6429        tracing::info!(
6430            prompt_tokens = tokens.len(),
6431            max_tokens = params.max_tokens,
6432            timeout_secs = timeout.map(|t| t.as_secs()),
6433            "local prefill starting"
6434        );
6435
6436        // Reuse any cached matching prefix (prompt caching); prefill only the
6437        // new suffix. `begin_prompt` returns the position to start from; the
6438        // default (Qwen) clears and returns 0 (full re-prefill).
6439        let offset = backend.begin_prompt(&tokens);
6440        let logits = Self::catch_mlx("prefill", || backend.forward(&tokens[offset..], offset))
6441            .map_err(DriveError::BackendCorrupted)?;
6442        let mut next_token =
6443            Self::sample_from_logits(&logits, params).map_err(DriveError::Recoverable)?;
6444        let ttft_ms = Some(start.elapsed().as_millis() as u64);
6445
6446        // The loop below is silent for its whole duration, which is what made a
6447        // 24-minute decode indistinguishable from a wedged process in car#851.
6448        // This line alone would have exposed the real budget: the tracing span
6449        // records `max_tokens` at entry, BEFORE the routing layer widens it.
6450        tracing::info!(
6451            prompt_tokens = tokens.len(),
6452            max_tokens = params.max_tokens,
6453            prefill_ms = ttft_ms,
6454            timeout_secs = timeout.map(|t| t.as_secs()),
6455            "local decode starting"
6456        );
6457
6458        // Track why the loop ended: a natural EOS / stop-sequence finish, the
6459        // max_tokens budget (truncation), or the wall-clock ceiling.
6460        let mut natural_stop = false;
6461        let mut timed_out = false;
6462        let heartbeat = std::time::Duration::from_secs(LOCAL_DECODE_HEARTBEAT_SECS);
6463        let mut last_heartbeat = std::time::Duration::ZERO;
6464        for _ in 0..params.max_tokens {
6465            if eos_ids.contains(&next_token) {
6466                natural_stop = true;
6467                break;
6468            }
6469
6470            generated.push(next_token);
6471
6472            if !params.stop.is_empty() {
6473                let text_so_far = backend
6474                    .decode(&generated)
6475                    .map_err(DriveError::Recoverable)?;
6476                if params.stop.iter().any(|s| text_so_far.contains(s)) {
6477                    natural_stop = true;
6478                    break;
6479                }
6480            }
6481
6482            let elapsed = start.elapsed();
6483            if deadline_exceeded(elapsed, timeout) {
6484                timed_out = true;
6485                break;
6486            }
6487            if heartbeat_due(elapsed, last_heartbeat, heartbeat) {
6488                last_heartbeat = elapsed;
6489                tracing::info!(
6490                    completion_tokens = generated.len(),
6491                    max_tokens = params.max_tokens,
6492                    elapsed_secs = elapsed.as_secs_f64(),
6493                    tokens_per_sec =
6494                        generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON),
6495                    "local decode in progress"
6496                );
6497            }
6498
6499            let pos = tokens.len() + generated.len() - 1;
6500            let logits = Self::catch_mlx("forward", || backend.forward(&[next_token], pos))
6501                .map_err(DriveError::BackendCorrupted)?;
6502            next_token =
6503                Self::sample_from_logits(&logits, params).map_err(DriveError::Recoverable)?;
6504        }
6505
6506        let elapsed = start.elapsed();
6507        let tokens_per_sec = generated.len() as f64 / elapsed.as_secs_f64().max(f64::EPSILON);
6508        if timed_out {
6509            tracing::warn!(
6510                elapsed_secs = elapsed.as_secs_f64(),
6511                timeout_secs = timeout.map(|t| t.as_secs()),
6512                completion_tokens = generated.len(),
6513                max_tokens = params.max_tokens,
6514                tokens_per_sec,
6515                "local decode hit its wall-clock ceiling and was cut short; \
6516                 returning the partial response. Raise or disable it with \
6517                 CAR_LOCAL_DECODE_TIMEOUT_SECS (0 disables)."
6518            );
6519        } else {
6520            tracing::info!(
6521                elapsed_secs = elapsed.as_secs_f64(),
6522                completion_tokens = generated.len(),
6523                max_tokens = params.max_tokens,
6524                tokens_per_sec,
6525                natural_stop,
6526                "local decode finished"
6527            );
6528        }
6529
6530        let decoded = backend
6531            .decode(&generated)
6532            .map_err(DriveError::Recoverable)?;
6533        let text = tasks::generate::truncate_at_stop(&decoded, &params.stop);
6534        let stop_reason = Some(
6535            if timed_out {
6536                LOCAL_DECODE_TIMEOUT_STOP_REASON
6537            } else if natural_stop {
6538                "stop"
6539            } else {
6540                "length"
6541            }
6542            .to_string(),
6543        );
6544        Ok(LocalGeneration {
6545            text: tasks::generate::strip_thinking(&text, params.thinking),
6546            ttft_ms,
6547            stop_reason,
6548            prompt_tokens: tokens.len(),
6549            completion_tokens: generated.len(),
6550        })
6551    }
6552
6553    /// Generate text using the MLX backend.
6554    /// Mirrors the Candle generate loop but uses MlxBackend::forward which returns Vec<f32>.
6555    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6556    /// Returns `(text, time_to_first_token_ms, stop_reason)`. `stop_reason`
6557    /// is `Some("stop")` when generation ended at EOS or a stop sequence,
6558    /// `Some("length")` when it hit the `max_tokens` cap — the OpenAI spelling
6559    /// so [`InferenceResult::was_truncated`] works for local models too
6560    /// (previously local always reported `None`, so a max_tokens cutoff was
6561    /// indistinguishable from a clean finish and the truncation-retry path
6562    /// never fired locally) — and [`LOCAL_DECODE_TIMEOUT_STOP_REASON`]
6563    /// (`"local_decode_timeout"`) when the wall-clock decode ceiling cut it
6564    /// short (car#851).
6565    async fn generate_mlx(
6566        &self,
6567        req: GenerateRequest,
6568        model_id: &str,
6569        reservation: &mut resource_policy::LocalLoadReservation,
6570    ) -> Result<(String, Option<TokenUsage>, Option<u64>, Option<String>), InferenceError> {
6571        let schema = self
6572            .unified_registry
6573            .get(model_id)
6574            .cloned()
6575            .ok_or_else(|| {
6576                InferenceError::InferenceFailed(format!(
6577                    "generate_mlx: unknown schema id {model_id}"
6578                ))
6579            })?;
6580        let (handle, _retention) = self.ensure_mlx_backend(&schema, reservation).await?;
6581        // Serialize on the shared Metal device (see `mlx_device_lock`) before the
6582        // per-model lock + synchronous decode loop. The per-model mutex alone does
6583        // NOT prevent a device-level race with a concurrent embed/other-model MLX
6584        // eval — e.g. memory consolidation embedding on a DIFFERENT backend while
6585        // the coder generates — which hangs the one Metal device (the daemon
6586        // "wedge"). Held across the whole generation, released on function return.
6587        let formatted = tasks::generate::render_chat_prompt(&req);
6588        let params = req.params.clone();
6589        let model_id = model_id.to_string();
6590        let cache = self.mlx_backends.clone();
6591        let device_guard = Self::mlx_device_lock().lock_owned().await;
6592        let detached_lease = reservation.detached_lease();
6593
6594        // MLX prefill/decode is synchronous native work. Keep both the device
6595        // guard and the request's exact machine charge inside the blocking
6596        // closure: the Tokio runtime remains available for the raw WebSocket
6597        // cancel/deadline response, and abandoning that waiter cannot advertise
6598        // capacity while native work is still finishing.
6599        run_admitted_blocking(detached_lease, move || {
6600            let _device_guard = device_guard;
6601            let mut guard = handle.lock().map_err(|_| {
6602                InferenceError::InferenceFailed(format!(
6603                    "MLX backend mutex poisoned for {model_id}"
6604                ))
6605            })?;
6606            let backend: &mut backend::MlxBackend = &mut guard;
6607            let ctx_window = backend.context_length() as u64;
6608            match Self::drive_generation(backend, &formatted, &params) {
6609                Ok(gen) => {
6610                    let usage = TokenUsage {
6611                        prompt_tokens: gen.prompt_tokens as u64,
6612                        completion_tokens: gen.completion_tokens as u64,
6613                        total_tokens: (gen.prompt_tokens + gen.completion_tokens) as u64,
6614                        context_window: ctx_window,
6615                        ..Default::default()
6616                    };
6617                    Ok((gen.text, Some(usage), gen.ttft_ms, gen.stop_reason))
6618                }
6619                Err(backend::local::DriveError::Recoverable(error)) => Err(error),
6620                Err(backend::local::DriveError::BackendCorrupted(error)) => {
6621                    drop(guard);
6622                    cache.invalidate(&model_id);
6623                    Err(error)
6624                }
6625            }
6626        })
6627        .await
6628        .map_err(|error| {
6629            InferenceError::InferenceFailed(format!("native MLX task panicked: {error}"))
6630        })?
6631    }
6632
6633    /// Trait-object analogue of [`generate_mlx`](Self::generate_mlx) for
6634    /// NEW-architecture in-process backends (Gemma 4, …). The backend renders
6635    /// its own architecture-specific prompt; the shared decode loop does the
6636    /// rest. Evicts from `local_backends` on a caught panic.
6637    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6638    async fn generate_local(
6639        &self,
6640        req: GenerateRequest,
6641        model_id: &str,
6642        reservation: &mut resource_policy::LocalLoadReservation,
6643    ) -> Result<
6644        (
6645            String,
6646            Vec<tasks::generate::ToolCall>,
6647            Option<TokenUsage>,
6648            Option<u64>,
6649            Option<String>,
6650        ),
6651        InferenceError,
6652    > {
6653        let schema = self
6654            .unified_registry
6655            .get(model_id)
6656            .cloned()
6657            .ok_or_else(|| {
6658                InferenceError::InferenceFailed(format!(
6659                    "generate_local: unknown schema id {model_id}"
6660                ))
6661            })?;
6662        let (handle, _retention) = self.ensure_local_backend(&schema, reservation).await?;
6663        let params = req.params.clone();
6664        let model_id = model_id.to_string();
6665        let cache = self.local_backends.clone();
6666        let device_guard = Self::mlx_device_lock().lock_owned().await;
6667        let detached_lease = reservation.detached_lease();
6668
6669        run_admitted_blocking(detached_lease, move || {
6670            let _device_guard = device_guard;
6671            let mut guard = handle.lock().map_err(|_| {
6672                InferenceError::InferenceFailed(format!(
6673                    "local backend mutex poisoned for {model_id}"
6674                ))
6675            })?;
6676            let formatted = guard.render_prompt(&req)?;
6677            let backend: &mut dyn backend::local::TextDecoder = &mut **guard;
6678            let outcome = Self::drive_generation(backend, &formatted, &params);
6679            match outcome {
6680                Ok(gen) => {
6681                    let (clean, tool_calls) = guard.parse_tool_calls(&gen.text);
6682                    let usage = TokenUsage {
6683                        prompt_tokens: gen.prompt_tokens as u64,
6684                        completion_tokens: gen.completion_tokens as u64,
6685                        total_tokens: (gen.prompt_tokens + gen.completion_tokens) as u64,
6686                        context_window: guard.context_length() as u64,
6687                        ..Default::default()
6688                    };
6689                    Ok((clean, tool_calls, Some(usage), gen.ttft_ms, gen.stop_reason))
6690                }
6691                Err(backend::local::DriveError::Recoverable(error)) => Err(error),
6692                Err(backend::local::DriveError::BackendCorrupted(error)) => {
6693                    drop(guard);
6694                    cache.invalidate(&model_id);
6695                    Err(error)
6696                }
6697            }
6698        })
6699        .await
6700        .map_err(|error| {
6701            InferenceError::InferenceFailed(format!("native local task panicked: {error}"))
6702        })?
6703    }
6704
6705    /// Apply top-k then top-p (nucleus) truncation to a probability vector
6706    /// in place: keep the `top_k` highest-probability entries (k=0 disables),
6707    /// then keep the smallest prefix whose cumulative mass exceeds `top_p`
6708    /// (p>=1.0 disables), zeroing the rest and renormalizing. Pure and
6709    /// platform-independent (not cfg-gated) so it's unit-testable without an
6710    /// MLX backend. Mirrors the Candle sampler's ordering.
6711    #[allow(dead_code)] // used by the MLX sampler + unit tests; dead in car_skip_mlx builds
6712    fn apply_top_k_top_p(probs: &mut [f32], top_k: usize, top_p: f64) {
6713        // Top-k: zero everything outside the k highest probabilities.
6714        if top_k > 0 && top_k < probs.len() {
6715            let mut indexed: Vec<(usize, f32)> = probs.iter().copied().enumerate().collect();
6716            indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6717            let allowed: std::collections::HashSet<usize> =
6718                indexed[..top_k].iter().map(|(i, _)| *i).collect();
6719            for (i, p) in probs.iter_mut().enumerate() {
6720                if !allowed.contains(&i) {
6721                    *p = 0.0;
6722                }
6723            }
6724            let sum: f32 = probs.iter().sum();
6725            if sum > 0.0 {
6726                for p in probs.iter_mut() {
6727                    *p /= sum;
6728                }
6729            }
6730        }
6731
6732        // Top-p (nucleus): keep the smallest high-prob prefix exceeding top_p.
6733        if top_p < 1.0 {
6734            let mut indexed: Vec<(usize, f32)> = probs.iter().copied().enumerate().collect();
6735            indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6736            let mut cumsum = 0.0f32;
6737            let mut cutoff_idx = indexed.len();
6738            for (i, &(_, p)) in indexed.iter().enumerate() {
6739                cumsum += p;
6740                if cumsum > top_p as f32 {
6741                    cutoff_idx = i + 1;
6742                    break;
6743                }
6744            }
6745            let allowed: std::collections::HashSet<usize> =
6746                indexed[..cutoff_idx].iter().map(|(i, _)| *i).collect();
6747            for (i, p) in probs.iter_mut().enumerate() {
6748                if !allowed.contains(&i) {
6749                    *p = 0.0;
6750                }
6751            }
6752            let sum: f32 = probs.iter().sum();
6753            if sum > 0.0 {
6754                for p in probs.iter_mut() {
6755                    *p /= sum;
6756                }
6757            }
6758        }
6759    }
6760
6761    /// Sample a token from a logits Vec<f32> (shared by every local backend).
6762    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6763    fn sample_from_logits(logits: &[f32], params: &GenerateParams) -> Result<u32, InferenceError> {
6764        if params.temperature <= 0.0 {
6765            // Greedy: argmax
6766            let (idx, _) = logits
6767                .iter()
6768                .enumerate()
6769                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
6770                .ok_or_else(|| InferenceError::InferenceFailed("empty logits".into()))?;
6771            return Ok(idx as u32);
6772        }
6773
6774        // Temperature-scaled softmax sampling
6775        let temp = params.temperature as f32;
6776        let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
6777        let mut probs: Vec<f32> = logits
6778            .iter()
6779            .map(|&l| ((l - max_logit) / temp).exp())
6780            .collect();
6781        let sum: f32 = probs.iter().sum();
6782        for p in &mut probs {
6783            *p /= sum;
6784        }
6785
6786        // Top-k then top-p (nucleus) truncation. Pulled into a pure helper so
6787        // it's deterministic and unit-testable (only the final draw below uses
6788        // rng). top_k was previously absent here — a request specifying top_k
6789        // was silently a no-op on Apple Silicon while the Candle sampler
6790        // (tasks::generate::sample_token) honored it.
6791        Self::apply_top_k_top_p(&mut probs, params.top_k, params.top_p);
6792
6793        // Sample from distribution
6794        use rand::Rng;
6795        let mut rng = rand::rng();
6796        let r: f32 = rng.random();
6797        let mut cumsum = 0.0;
6798        for (i, &p) in probs.iter().enumerate() {
6799            cumsum += p;
6800            if cumsum >= r {
6801                return Ok(i as u32);
6802            }
6803        }
6804        Ok((probs.len() - 1) as u32)
6805    }
6806
6807    /// Generate embeddings for text using the dedicated embedding model.
6808    /// On Apple Silicon, uses the native MLX backend; on other platforms, uses Candle.
6809    pub async fn embed(&self, req: EmbedRequest) -> Result<Vec<Vec<f32>>, InferenceError> {
6810        let instruction = req
6811            .instruction
6812            .as_deref()
6813            .unwrap_or("Retrieve relevant memory facts");
6814        let embedding_model = self
6815            .preferred_model_for_capability(ModelCapability::Embed)
6816            .unwrap_or(&self.config.embedding_model);
6817        let admission_schema = self
6818            .unified_registry
6819            .get(embedding_model)
6820            .or_else(|| self.unified_registry.find_by_name(embedding_model))
6821            .ok_or_else(|| InferenceError::ModelNotFound(embedding_model.to_string()))?
6822            .clone();
6823        let estimated_tokens = req.texts.iter().map(|text| text.len().div_ceil(4)).sum();
6824        // LOCAL_ADMISSION_BOUNDARY:embedding-dispatch
6825        let mut reservation = self.reserve_local_request(&admission_schema, estimated_tokens)?;
6826
6827        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6828        {
6829            let model_id = self.ensure_mlx_embedding_backend().await?;
6830            let schema = self
6831                .unified_registry
6832                .get(&model_id)
6833                .cloned()
6834                .ok_or_else(|| {
6835                    InferenceError::InferenceFailed(format!("embed: unknown schema id {model_id}"))
6836                })?;
6837            let (handle, _retention) = self.ensure_mlx_backend(&schema, &mut reservation).await?;
6838            // Device serialization (see `generate_mlx`): this embed path is the
6839            // memory-consolidation caller that was racing the coder's generate on
6840            // a different backend and wedging the Metal device. Same device lock.
6841            let _device_guard = Self::mlx_device_lock().lock_owned().await;
6842            let mut guard = handle.lock().map_err(|_| {
6843                InferenceError::InferenceFailed(format!(
6844                    "MLX embedding backend mutex poisoned for {model_id}"
6845                ))
6846            })?;
6847            let backend: &mut backend::MlxBackend = &mut guard;
6848
6849            let mut results = Vec::with_capacity(req.texts.len());
6850            for text in &req.texts {
6851                let embedding = if req.is_query {
6852                    backend.embed_query(text, instruction)?
6853                } else {
6854                    backend.embed_one(text)?
6855                };
6856                results.push(embedding);
6857            }
6858            Ok(results)
6859        }
6860
6861        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
6862        {
6863            self.ensure_embedding_backend(&mut reservation).await?;
6864            let mut write = self.embedding_backend.write().await;
6865            let backend = write.as_mut().unwrap();
6866
6867            let mut results = Vec::with_capacity(req.texts.len());
6868            for text in &req.texts {
6869                let embedding = if req.is_query {
6870                    backend.embed_query(text, instruction)?
6871                } else {
6872                    backend.embed_one(text)?
6873                };
6874                results.push(embedding);
6875            }
6876            Ok(results)
6877        }
6878    }
6879
6880    /// Rerank candidate documents against a query using a cross-encoder
6881    /// reranker model (Qwen3-Reranker family). Returns documents sorted
6882    /// by descending relevance.
6883    ///
6884    /// ## Scoring
6885    ///
6886    /// Qwen3-Reranker is a Qwen3 base LM fine-tuned so that the first
6887    /// assistant token is `"yes"` or `"no"` given the templated
6888    /// `<Instruct>/<Query>/<Document>` user turn. We run a short
6889    /// greedy decode (≤ 3 tokens, so a leading space, BOS artifact, or
6890    /// the occasional newline don't break us) and score
6891    /// `yes → 1.0`, `no → 0.0`, anything else → `0.5` with a warning.
6892    ///
6893    /// This is a **binary** score — the soft probability
6894    /// `softmax(logit_yes, logit_no)` would give finer ordering but
6895    /// requires per-token logit access on `backend::MlxBackend`,
6896    /// which isn't exposed publicly yet. Tracked as a follow-up;
6897    /// binary scores still produce a correct partial ordering, just
6898    /// with coarser tiebreaks within the {yes} or {no} groups.
6899    ///
6900    /// ## Prompt template
6901    ///
6902    /// We emit the upstream Qwen3-Reranker chat template verbatim:
6903    /// a dedicated system prompt fixing the yes/no answer space,
6904    /// then the user turn with `<Instruct>/<Query>/<Document>`, then
6905    /// the assistant prefix with a closed empty `<think>` block to
6906    /// suppress thinking (reranker is not a reasoner — it's a
6907    /// classifier). Deviating from this template produces sharply
6908    /// degraded yes/no distributions.
6909    pub async fn rerank(&self, req: RerankRequest) -> Result<RerankResult, InferenceError> {
6910        if req.documents.is_empty() {
6911            return Ok(RerankResult {
6912                ranked: Vec::new(),
6913                model_used: None,
6914            });
6915        }
6916
6917        let model_name = match req.model.clone() {
6918            Some(m) => m,
6919            None => self
6920                .preferred_model_for_capability(ModelCapability::Rerank)
6921                .map(str::to_string)
6922                .ok_or_else(|| {
6923                    InferenceError::InferenceFailed(
6924                        "no reranker model available — pull a Qwen3-Reranker model first".into(),
6925                    )
6926                })?,
6927        };
6928
6929        let schema = self
6930            .unified_registry
6931            .find_by_name(&model_name)
6932            .or_else(|| self.unified_registry.get(&model_name))
6933            .cloned()
6934            .ok_or_else(|| {
6935                InferenceError::InferenceFailed(format!(
6936                    "rerank: unknown reranker model {model_name}"
6937                ))
6938            })?;
6939        if !schema.has_capability(ModelCapability::Rerank) {
6940            return Err(InferenceError::InferenceFailed(format!(
6941                "model {} does not declare the Rerank capability",
6942                schema.name
6943            )));
6944        }
6945
6946        let instruction = req.instruction.as_deref().unwrap_or(
6947            "Given a web search query, retrieve relevant passages that answer the query",
6948        );
6949
6950        let mut scored: Vec<RerankedDocument> = Vec::with_capacity(req.documents.len());
6951        for (idx, doc) in req.documents.iter().enumerate() {
6952            let prompt = rerank_prompt(instruction, &req.query, doc);
6953            let gen_req = GenerateRequest {
6954                prompt,
6955                model: Some(schema.id.clone()),
6956                params: tasks::generate::GenerateParams {
6957                    temperature: 0.0,
6958                    // Three tokens is enough to scan past a leading
6959                    // space, BOS, or newline that some tokenizers
6960                    // insert before the real yes/no token.
6961                    max_tokens: 3,
6962                    thinking: tasks::generate::ThinkingMode::Off,
6963                    ..Default::default()
6964                },
6965                context: None,
6966                context_stable_prefix: None,
6967                tools: None,
6968                images: None,
6969                messages: None,
6970                cache_control: false,
6971                response_format: None,
6972                intent: None,
6973                client_ref: None,
6974                expected_row_digest: None,
6975                expected_catalog_revision: None,
6976                caller: None,
6977            };
6978            let out = self.generate(gen_req).await?;
6979            let score = score_from_rerank_output(&out, &schema.name);
6980            scored.push(RerankedDocument {
6981                index: idx,
6982                score,
6983                document: doc.clone(),
6984            });
6985        }
6986
6987        // Sort descending by score; preserve original index as a
6988        // deterministic tiebreaker. top_n must truncate after sorting.
6989        scored.sort_by(|a, b| {
6990            b.score
6991                .partial_cmp(&a.score)
6992                .unwrap_or(std::cmp::Ordering::Equal)
6993                .then_with(|| a.index.cmp(&b.index))
6994        });
6995        if let Some(n) = req.top_n {
6996            scored.truncate(n);
6997        }
6998
6999        Ok(RerankResult {
7000            ranked: scored,
7001            model_used: Some(schema.name),
7002        })
7003    }
7004
7005    /// Dedicated endpoint for structured visual grounding.
7006    ///
7007    /// Runs a VL generate call under the hood and parses Qwen2.5-VL's
7008    /// inline `<|object_ref_*|>...<|box_*|>(x1,y1),(x2,y2)` spans into
7009    /// typed [`BoundingBox`]es. Distinct from the generic
7010    /// [`InferenceEngine::generate`] + `InferenceResult.bounding_boxes`
7011    /// path so callers can express "I want boxes" as a first-class
7012    /// intent — which also lets the router prefer models that declare
7013    /// the `Grounding` capability.
7014    pub async fn ground(&self, req: GroundRequest) -> Result<GroundResult, InferenceError> {
7015        let model_name = match req.model.clone() {
7016            Some(m) => m,
7017            None => self
7018                .preferred_model_for_capability(ModelCapability::Grounding)
7019                .map(str::to_string)
7020                .ok_or_else(|| {
7021                    InferenceError::InferenceFailed(
7022                        "no grounding-capable model available — pull a Qwen2.5-VL model first"
7023                            .into(),
7024                    )
7025                })?,
7026        };
7027
7028        let gen_req = GenerateRequest {
7029            prompt: req.prompt.clone(),
7030            model: Some(model_name),
7031            params: GenerateParams::default(),
7032            context: None,
7033            context_stable_prefix: None,
7034            tools: None,
7035            images: Some(vec![req.image.clone()]),
7036            messages: None,
7037            cache_control: false,
7038            response_format: None,
7039            intent: None,
7040            client_ref: None,
7041            expected_row_digest: None,
7042            expected_catalog_revision: None,
7043            caller: None,
7044        };
7045        let result = self.generate_tracked(gen_req).await?;
7046        Ok(GroundResult {
7047            boxes: result.bounding_boxes,
7048            raw_text: result.text,
7049            model_used: Some(result.model_used),
7050        })
7051    }
7052
7053    /// Classify text against candidate labels.
7054    /// When `req.model` is None, routes to the smallest available model.
7055    pub async fn classify(
7056        &self,
7057        req: ClassifyRequest,
7058    ) -> Result<Vec<ClassifyResult>, InferenceError> {
7059        let model = match req.model.clone().or_else(|| {
7060            self.preferred_model_for_capability(ModelCapability::Classify)
7061                .map(str::to_string)
7062        }) {
7063            Some(m) => m,
7064            None => {
7065                let m = self.router.route_small(&self.registry);
7066                debug!(model = %m, "auto-routed classify request");
7067                m
7068            }
7069        };
7070        let schema = self
7071            .unified_registry
7072            .get(&model)
7073            .or_else(|| self.unified_registry.find_by_name(&model))
7074            .ok_or_else(|| InferenceError::ModelNotFound(model.clone()))?
7075            .clone();
7076
7077        if !schema.is_local() {
7078            return self.classify_via_generate(req, &schema.id).await;
7079        }
7080
7081        // On Apple Silicon, route through the main generate path (which uses MLX)
7082        // instead of the Candle backend directly.
7083        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7084        {
7085            return self.classify_via_generate(req, &model).await;
7086        }
7087
7088        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
7089        {
7090            // LOCAL_ADMISSION_BOUNDARY:candle-classify
7091            let mut reservation =
7092                self.reserve_local_request(&schema, req.text.len().div_ceil(4))?;
7093            self.ensure_backend(&schema, &mut reservation).await?;
7094            let mut write = self.backend.write().await;
7095            let backend = write.get_mut(&schema.id).ok_or_else(|| {
7096                InferenceError::InferenceFailed(format!(
7097                    "candle backend missing after ensure_backend for {}",
7098                    schema.id
7099                ))
7100            })?;
7101            let result = tasks::classify::classify(backend, req).await?;
7102            Ok(result)
7103        }
7104    }
7105
7106    /// Classify by routing through the main generate path (remote providers on
7107    /// every platform; native MLX on Apple Silicon).
7108    async fn classify_via_generate(
7109        &self,
7110        req: ClassifyRequest,
7111        model: &str,
7112    ) -> Result<Vec<ClassifyResult>, InferenceError> {
7113        let labels_str = req
7114            .labels
7115            .iter()
7116            .enumerate()
7117            .map(|(i, l)| format!("{}. {}", i + 1, l))
7118            .collect::<Vec<_>>()
7119            .join("\n");
7120
7121        let prompt = format!(
7122            "Classify the following text into one of these categories:\n\
7123             {labels_str}\n\n\
7124             Text: {}\n\n\
7125             Respond with ONLY the category name, nothing else.",
7126            req.text
7127        );
7128
7129        let gen_req = GenerateRequest {
7130            prompt,
7131            model: Some(model.to_string()),
7132            params: tasks::generate::GenerateParams {
7133                temperature: 0.0,
7134                max_tokens: 32,
7135                // Classification is latency-sensitive and single-label;
7136                // force the fast no-thinking path even on Qwen3.
7137                thinking: tasks::generate::ThinkingMode::Off,
7138                ..Default::default()
7139            },
7140            context: None,
7141            context_stable_prefix: None,
7142            tools: None,
7143            images: None,
7144            messages: None,
7145            cache_control: false,
7146            response_format: None,
7147            intent: None,
7148            client_ref: None,
7149            expected_row_digest: None,
7150            expected_catalog_revision: None,
7151            caller: None,
7152        };
7153
7154        let response = self.generate(gen_req).await?;
7155        let response_lower = response.trim().to_lowercase();
7156
7157        let mut results: Vec<ClassifyResult> = req
7158            .labels
7159            .iter()
7160            .map(|label| {
7161                let label_lower = label.to_lowercase();
7162                let score = if response_lower == label_lower {
7163                    1.0
7164                } else if response_lower.contains(&label_lower) {
7165                    0.8
7166                } else {
7167                    let label_words: Vec<&str> = label_lower.split_whitespace().collect();
7168                    let matches = label_words
7169                        .iter()
7170                        .filter(|w| response_lower.contains(**w))
7171                        .count();
7172                    if label_words.is_empty() {
7173                        0.0
7174                    } else {
7175                        0.5 * (matches as f64 / label_words.len() as f64)
7176                    }
7177                };
7178                ClassifyResult {
7179                    label: label.clone(),
7180                    score,
7181                }
7182            })
7183            .collect();
7184
7185        results.sort_by(|a, b| {
7186            b.score
7187                .partial_cmp(&a.score)
7188                .unwrap_or(std::cmp::Ordering::Equal)
7189        });
7190
7191        let total: f64 = results.iter().map(|r| r.score).sum();
7192        if total > 0.0 {
7193            for r in &mut results {
7194                r.score /= total;
7195            }
7196        }
7197
7198        Ok(results)
7199    }
7200
7201    /// Transcribe an audio file using the best available STT model.
7202    pub async fn transcribe(
7203        &self,
7204        req: TranscribeRequest,
7205    ) -> Result<TranscribeResult, InferenceError> {
7206        let candidates =
7207            self.speech_candidates(ModelCapability::SpeechToText, req.model.as_deref())?;
7208        let mut last_error = None;
7209        let mut local_resource_block = None;
7210
7211        for schema in candidates {
7212            // LOCAL_ADMISSION_BOUNDARY:speech-stt-dispatch
7213            let mut reservation = match self.admit_speech_candidate(&schema, req.model.is_some()) {
7214                SpeechCandidateAdmission::Proceed(reservation) => reservation,
7215                SpeechCandidateAdmission::SkipBlocked(error) => {
7216                    local_resource_block = Some(error.to_string());
7217                    last_error = Some(error);
7218                    continue;
7219                }
7220                SpeechCandidateAdmission::FailBlocked(error) => return Err(error),
7221            };
7222            let result = match &schema.source {
7223                ModelSource::Mlx { .. } => {
7224                    self.transcribe_local_mlx(&schema, &req, reservation.as_mut())
7225                        .await
7226                }
7227                ModelSource::WhisperCpp { model } => {
7228                    self.transcribe_whisper(&schema, model, &req, reservation.as_mut())
7229                        .await
7230                }
7231                ModelSource::Proprietary { provider, .. } if provider == "elevenlabs" => {
7232                    self.transcribe_elevenlabs(&schema, &req).await
7233                }
7234                _ => Err(InferenceError::InferenceFailed(format!(
7235                    "speech-to-text not implemented for model source: {}",
7236                    schema.id
7237                ))),
7238            };
7239
7240            match result {
7241                Ok(mut result) => {
7242                    if matches!(schema.source, ModelSource::Proprietary { .. }) {
7243                        if let Some(reason) = local_resource_block.take() {
7244                            result.routing_explanation = Some(format!(
7245                                "Local speech recognition was blocked ({reason}); CAR routed to a remote provider, which may affect privacy and cost."
7246                            ));
7247                        }
7248                    }
7249                    return Ok(result);
7250                }
7251                Err(err) if req.model.is_some() => return Err(err),
7252                Err(err) => last_error = Some(err),
7253            }
7254        }
7255
7256        Err(last_error.unwrap_or_else(|| {
7257            InferenceError::InferenceFailed("no speech-to-text models available".into())
7258        }))
7259    }
7260
7261    /// Synthesize speech using the best available TTS model.
7262    pub async fn synthesize(
7263        &self,
7264        req: SynthesizeRequest,
7265    ) -> Result<SynthesizeResult, InferenceError> {
7266        let candidates =
7267            self.speech_candidates(ModelCapability::TextToSpeech, req.model.as_deref())?;
7268        let mut last_error = None;
7269        let mut local_resource_block = None;
7270
7271        for schema in candidates {
7272            // LOCAL_ADMISSION_BOUNDARY:speech-tts-dispatch
7273            let mut reservation = match self.admit_speech_candidate(&schema, req.model.is_some()) {
7274                SpeechCandidateAdmission::Proceed(reservation) => reservation,
7275                SpeechCandidateAdmission::SkipBlocked(error) => {
7276                    local_resource_block = Some(error.to_string());
7277                    last_error = Some(error);
7278                    continue;
7279                }
7280                SpeechCandidateAdmission::FailBlocked(error) => return Err(error),
7281            };
7282            let result = match &schema.source {
7283                ModelSource::Mlx { .. } => {
7284                    self.synthesize_local_mlx(&schema, &req, reservation.as_mut())
7285                        .await
7286                }
7287                ModelSource::WindowsSpeech {} => {
7288                    self.synthesize_windows_speech(&schema, &req).await
7289                }
7290                ModelSource::Proprietary { provider, .. } if provider == "elevenlabs" => {
7291                    self.synthesize_elevenlabs(&schema, &req).await
7292                }
7293                _ => Err(InferenceError::InferenceFailed(format!(
7294                    "text-to-speech not implemented for model source: {}",
7295                    schema.id
7296                ))),
7297            };
7298
7299            match result {
7300                Ok(mut result) => {
7301                    if matches!(schema.source, ModelSource::Proprietary { .. }) {
7302                        if let Some(reason) = local_resource_block.take() {
7303                            result.routing_explanation = Some(format!(
7304                                "Local speech synthesis was blocked ({reason}); CAR routed to a remote provider, which may affect privacy and cost."
7305                            ));
7306                        }
7307                    }
7308                    return Ok(result);
7309                }
7310                Err(err) if req.model.is_some() => return Err(err),
7311                Err(err) => last_error = Some(err),
7312            }
7313        }
7314
7315        Err(last_error.unwrap_or_else(|| {
7316            InferenceError::InferenceFailed("no text-to-speech models available".into())
7317        }))
7318    }
7319
7320    /// Synthesize speech with the Windows OS synthesizer (`WindowsSpeech`,
7321    /// WinRT `Windows.Media.SpeechSynthesis`) — the catalog-side counterpart of
7322    /// car-voice's live `TtsProvider::WindowsSpeech`. Writes a WAV file at the
7323    /// requested (or a temp) path. Windows-only; the availability gate keeps
7324    /// this off the candidate list on every other target.
7325    async fn synthesize_windows_speech(
7326        &self,
7327        schema: &ModelSchema,
7328        req: &SynthesizeRequest,
7329    ) -> Result<SynthesizeResult, InferenceError> {
7330        #[cfg(target_os = "windows")]
7331        {
7332            let text = req.text.clone();
7333            let voice = req.voice.clone().unwrap_or_default();
7334            let rate = req.speed.unwrap_or(1.0) as f64;
7335            let bytes =
7336                tokio::task::spawn_blocking(move || winrt_synthesize_wav(&text, &voice, rate))
7337                    .await
7338                    .map_err(|e| {
7339                        InferenceError::InferenceFailed(format!("winrt tts join: {e}"))
7340                    })??;
7341            let dest = requested_or_temp_output(req.output_path.as_deref(), "wav")?;
7342            ensure_parent_dir(&dest)?;
7343            std::fs::write(&dest, &bytes)?;
7344            Ok(SynthesizeResult {
7345                audio_path: dest.to_string_lossy().to_string(),
7346                media_type: "audio/wav".to_string(),
7347                model_used: Some(schema.name.clone()),
7348                voice_used: req.voice.clone(),
7349                routing_explanation: None,
7350            })
7351        }
7352        #[cfg(not(target_os = "windows"))]
7353        {
7354            let _ = (schema, req);
7355            Err(InferenceError::InferenceFailed(
7356                "Windows OS TTS is only available on Windows".into(),
7357            ))
7358        }
7359    }
7360
7361    /// Generate an image using the best available local MLX image model.
7362    pub async fn generate_image(
7363        &self,
7364        req: GenerateImageRequest,
7365    ) -> Result<GenerateImageResult, InferenceError> {
7366        // Dispatch on what the native backend can actually load, not on an env
7367        // toggle. `mlx_flux` implements the Flux.1-lite architecture and reached
7368        // prompt-faithful parity with mflux (parity harness in
7369        // tools/parity/ref_flux.py + diff_flux_blocks.py), so it serves that
7370        // checkpoint. Every other architecture — FLUX.2, Krea 2, Qwen-Image,
7371        // Z-Image, Fibo — has no Rust implementation and goes to mflux.
7372        //
7373        // This is the same rule the text path uses (`backend::local::
7374        // has_native_backend`): the model decides the backend. The previous
7375        // `CAR_IMAGE_BACKEND` env toggle existed for A/B comparison *during* the
7376        // parity migration; that migration completed, and a toggle outliving its
7377        // purpose is exactly the "works for me but not for you" failure mode
7378        // CLAUDE.md's no-flags rule exists to prevent. It also defaulted to
7379        // native unconditionally, so asking for FLUX.2 silently got a backend
7380        // that cannot load it.
7381        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7382        {
7383            use crate::backend::external_flux;
7384            let use_external = !external_flux::native_backend_serves(req.model.as_deref());
7385            if use_external {
7386                tracing::info!(
7387                    model = req.model.as_deref().unwrap_or("<default>"),
7388                    "no native Rust implementation for this image architecture; routing to mflux"
7389                );
7390                let schema = self
7391                    .media_generation_candidates(
7392                        ModelCapability::ImageGeneration,
7393                        req.model.as_deref(),
7394                    )?
7395                    .into_iter()
7396                    .next()
7397                    .ok_or_else(|| {
7398                        InferenceError::InferenceFailed(
7399                            "no image generation models available".into(),
7400                        )
7401                    })?;
7402                // LOCAL_ADMISSION_BOUNDARY:image-external-subprocess
7403                let mut reservation = self.reserve_local_request(&schema, 0)?;
7404                let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7405                reservation
7406                    .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
7407                    .map_err(InferenceError::from)?;
7408                let mut req = req;
7409                req.model = self.resolve_external_hf_repo(
7410                    req.model.as_deref(),
7411                    ModelCapability::ImageGeneration,
7412                );
7413                return external_flux::generate_image(&req);
7414            }
7415            tracing::info!("using native Rust MLX Flux backend");
7416        }
7417
7418        let candidates = self
7419            .media_generation_candidates(ModelCapability::ImageGeneration, req.model.as_deref())?;
7420        let mut last_error = None;
7421
7422        for schema in candidates {
7423            // LOCAL_ADMISSION_BOUNDARY:image-dispatch
7424            #[cfg_attr(
7425                not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))),
7426                allow(unused_mut, unused_variables)
7427            )]
7428            let mut reservation = self.reserve_local_request(&schema, 0)?;
7429            let result = match &schema.source {
7430                #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7431                ModelSource::Mlx { .. } => {
7432                    self.generate_image_native_mlx(&schema, &req, &mut reservation)
7433                        .await
7434                }
7435                _ => Err(InferenceError::InferenceFailed(format!(
7436                    "image generation not implemented for model source: {}",
7437                    schema.id
7438                ))),
7439            };
7440
7441            match result {
7442                Ok(result) => return Ok(result),
7443                Err(err) if req.model.is_some() => return Err(err),
7444                Err(err) => last_error = Some(err),
7445            }
7446        }
7447
7448        Err(last_error.unwrap_or_else(|| {
7449            InferenceError::InferenceFailed("no image generation models available".into())
7450        }))
7451    }
7452
7453    /// Generate one or more variants in a single call.
7454    ///
7455    /// Returns `req.variant_count` results (defaulting to 1). The
7456    /// current MLX Flux backend doesn't support native batching, so
7457    /// this loops over `generate_image` with the seed advanced per
7458    /// variant for visual diversity. A future hosted backend
7459    /// (gpt-image-2, Replicate) can short-circuit this with one
7460    /// network call producing N coherent images.
7461    ///
7462    /// Per-variant errors abort the batch — there's no partial-
7463    /// success semantics today. Callers needing more lenient
7464    /// behaviour should call `generate_image` directly in their own
7465    /// loop.
7466    ///
7467    /// Closes #110.
7468    pub async fn generate_image_batch(
7469        &self,
7470        req: GenerateImageRequest,
7471    ) -> Result<Vec<GenerateImageResult>, InferenceError> {
7472        let count = req.variant_count.unwrap_or(1).max(1);
7473        if count == 1 {
7474            return self.generate_image(req).await.map(|r| vec![r]);
7475        }
7476        let base_seed = req.seed.unwrap_or(0);
7477        let mut results = Vec::with_capacity(count as usize);
7478        for i in 0..count {
7479            // Vary the seed per variant so backends that key prompt
7480            // → output deterministically actually produce different
7481            // images. Callers wanting reproducible single-seed
7482            // variants override `seed` per call themselves.
7483            let mut variant_req = req.clone();
7484            variant_req.seed = Some(base_seed.wrapping_add(i as u64));
7485            // Suppress variant_count on the inner call to avoid
7486            // recursion — generate_image ignores the field today,
7487            // but this also documents intent.
7488            variant_req.variant_count = Some(1);
7489            results.push(self.generate_image(variant_req).await?);
7490        }
7491        Ok(results)
7492    }
7493
7494    /// One process-wide lock over the single Metal device, shared by EVERY
7495    /// native MLX generate path (flux image, ltx video, and any future MLX
7496    /// media backend such as kokoro TTS). Two concurrent MLX evals race the
7497    /// command encoder and segfault the whole process inside
7498    /// `mlx::core::metal::Device::end_encoding`. The per-model `handle.lock()`
7499    /// only serializes calls that share ONE cached backend — the LRU cache can
7500    /// hand a second call a freshly-loaded instance on a different mutex, and
7501    /// image-vs-video are different mutexes entirely — so a device-wide lock is
7502    /// the only thing that actually serializes the GPU. Held *inside* the
7503    /// `spawn_blocking` closure so it survives RPC-deadline abandonment of the
7504    /// outer future.
7505    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7506    fn mlx_device_lock() -> Arc<tokio::sync::Mutex<()>> {
7507        static MLX_DEVICE_LOCK: std::sync::OnceLock<Arc<tokio::sync::Mutex<()>>> =
7508            std::sync::OnceLock::new();
7509        MLX_DEVICE_LOCK
7510            .get_or_init(|| Arc::new(tokio::sync::Mutex::new(())))
7511            .clone()
7512    }
7513
7514    /// Native MLX Flux image generation (no Python shelling).
7515    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7516    async fn generate_image_native_mlx(
7517        &self,
7518        schema: &ModelSchema,
7519        req: &GenerateImageRequest,
7520        reservation: &mut resource_policy::LocalLoadReservation,
7521    ) -> Result<GenerateImageResult, InferenceError> {
7522        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7523        let size = backend_cache::estimate_model_size(&model_dir);
7524        // LOCAL_ADMISSION_BOUNDARY:image-dispatch
7525        let (handle, _retention) = Self::load_backend_healing(
7526            &schema.id,
7527            model_dir,
7528            &self.flux_cache,
7529            size,
7530            reservation,
7531            backend::mlx_flux::FluxBackend::load,
7532            || self.unified_registry.redownload_local(&schema.id),
7533        )
7534        .await?;
7535        // Serialize on the shared Metal device (see `mlx_device_lock`) before
7536        // running the synchronous, GPU-bound eval on a blocking worker. The
7537        // per-model mutex alone does NOT prevent a device-level race with a
7538        // concurrent video/other-model eval.
7539        let req = req.clone();
7540        let device_guard = Self::mlx_device_lock().lock_owned().await;
7541        tokio::task::spawn_blocking(move || -> Result<GenerateImageResult, InferenceError> {
7542            // Held for the full native eval; released at closure end.
7543            let _device_guard = device_guard;
7544            let mut guard = handle.lock().map_err(|_| {
7545                InferenceError::InferenceFailed("flux backend mutex poisoned".into())
7546            })?;
7547            guard.generate(&req)
7548        })
7549        .await
7550        .map_err(|e| InferenceError::InferenceFailed(format!("flux task join: {e}")))?
7551    }
7552
7553    /// Native MLX LTX-2.3 video generation (no Python shelling).
7554    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7555    async fn generate_video_native_mlx(
7556        &self,
7557        schema: &ModelSchema,
7558        req: &GenerateVideoRequest,
7559        reservation: &mut resource_policy::LocalLoadReservation,
7560    ) -> Result<GenerateVideoResult, InferenceError> {
7561        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7562        let size = backend_cache::estimate_model_size(&model_dir);
7563        // LOCAL_ADMISSION_BOUNDARY:video-dispatch
7564        let (handle, _retention) = Self::load_backend_healing(
7565            &schema.id,
7566            model_dir,
7567            &self.ltx_cache,
7568            size,
7569            reservation,
7570            backend::mlx_ltx::LtxBackend::load,
7571            || self.unified_registry.redownload_local(&schema.id),
7572        )
7573        .await?;
7574        let req = req.clone();
7575
7576        // Process-wide single-permit lock on the in-process MLX *video*
7577        // device eval. Two concurrent MLX evals on the one Metal device
7578        // race the command encoder and segfault the whole daemon inside
7579        // `mlx::core::metal::Device::end_encoding` (null encoder; observed
7580        // 2026-06-24, two `LtxBackend::generate` threads live at once).
7581        //
7582        // Neither existing guard prevents this:
7583        //   * the admission semaphore (`car-server-core::admission`) is
7584        //     RAM-sized (≈1 permit / 8 GB, up to 8) — it bounds LLM
7585        //     activations, not GPU eval, so it freely admits N>1 video
7586        //     generations on a roomy host;
7587        //   * the per-instance `handle.lock()` below only serializes
7588        //     calls that share ONE cached backend — the LRU cache can
7589        //     hand a second call a freshly-loaded instance (esp. after a
7590        //     deadline-orphaned first call), so the two lock different
7591        //     mutexes and run the device concurrently.
7592        //
7593        // This lock is independent of both: it gates the device itself.
7594        // The guard is MOVED into the blocking closure rather than held
7595        // by this async future, so it survives an RPC-deadline abandon:
7596        // a `spawn_blocking` job can't be cancelled, so the orphaned
7597        // native eval keeps the lock until it actually finishes and the
7598        // next video eval waits instead of overlapping (and crashing).
7599        // NOTE: other in-process MLX backends (flux image, kokoro TTS)
7600        // share the same Metal device and should adopt this lock too for
7601        // full cross-modality coverage — tracked as a follow-up; this
7602        // change fixes the observed video-vs-video crash.
7603        // Shared with flux image + any future MLX media path (see
7604        // `mlx_device_lock`) — a video eval must not run concurrently with an
7605        // image eval on the one Metal device.
7606        let device_guard = Self::mlx_device_lock().lock_owned().await;
7607
7608        tokio::task::spawn_blocking(move || -> Result<GenerateVideoResult, InferenceError> {
7609            // Held for the full native eval; released at closure end,
7610            // which is what lets the next waiting video eval proceed.
7611            let _device_guard = device_guard;
7612            let mut guard = handle.lock().map_err(|_| {
7613                InferenceError::InferenceFailed("ltx backend mutex poisoned".into())
7614            })?;
7615            guard.generate(&req)
7616        })
7617        .await
7618        .map_err(|e| InferenceError::InferenceFailed(format!("ltx task join: {e}")))?
7619    }
7620
7621    /// Generate a video using the best available local MLX video model.
7622    pub async fn generate_video(
7623        &self,
7624        req: GenerateVideoRequest,
7625    ) -> Result<GenerateVideoResult, InferenceError> {
7626        // Validate the request shape up front so callers get a clean
7627        // error rather than a backend failure deep in the stack.
7628        if let Err(msg) = req.validate() {
7629            return Err(InferenceError::InferenceFailed(format!(
7630                "invalid GenerateVideoRequest: {}",
7631                msg
7632            )));
7633        }
7634        // Consumed only by the MLX LTX video path below; unused on non-MLX builds.
7635        #[allow(unused_variables)]
7636        let requires_audio_conditioning = req.requires_audio_passthrough_opt_in();
7637        // Backend selection: LTX can use CAR's native Rust MLX backend,
7638        // or the legacy external `ltx-2-mlx` bridge when requested.
7639        let candidates = self
7640            .media_generation_candidates(ModelCapability::VideoGeneration, req.model.as_deref())?;
7641        let mut last_error = None;
7642
7643        for schema in candidates {
7644            // LOCAL_ADMISSION_BOUNDARY:video-dispatch
7645            let mut reservation = self.reserve_local_request(&schema, 0)?;
7646            let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
7647            reservation
7648                .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
7649                .map_err(InferenceError::from)?;
7650            let result = match &schema.source {
7651                #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
7652                ModelSource::Mlx { hf_repo, .. } => {
7653                    // The native Rust LTX port reaches quality parity with
7654                    // upstream `ltx-2-mlx` (#40 / #45), so it serves ordinary
7655                    // generation. The one thing it genuinely cannot do is
7656                    // audio-reference conditioning, which only the external
7657                    // `ltx-2-mlx a2v` CLI implements — that is a capability
7658                    // difference, not a preference, so it is what decides here.
7659                    //
7660                    // The former `CAR_VIDEO_BACKEND` toggle existed to A/B the
7661                    // port against the CLI while it reached parity. Parity
7662                    // landed and the toggle outlived it, leaving a runtime knob
7663                    // that silently changed which engine ran — the "works for
7664                    // me but not for you" failure mode CLAUDE.md's no-flags rule
7665                    // exists to prevent.
7666                    let use_external_ltx = requires_audio_conditioning;
7667                    if requires_audio_conditioning && !crate::backend::external_ltx::is_available()
7668                    {
7669                        return Err(InferenceError::InferenceFailed(
7670                            "audio-reference video conditioning requires the external `ltx-2-mlx a2v` CLI on PATH"
7671                                .to_string(),
7672                        ));
7673                    }
7674                    if use_external_ltx {
7675                        let mut req = req.clone();
7676                        req.model = Some(hf_repo.clone());
7677                        crate::backend::external_ltx::generate_video(&req)
7678                    } else {
7679                        self.generate_video_native_mlx(&schema, &req, &mut reservation)
7680                            .await
7681                    }
7682                }
7683                _ => Err(InferenceError::InferenceFailed(format!(
7684                    "video generation not implemented for model source: {}",
7685                    schema.id
7686                ))),
7687            };
7688
7689            match result {
7690                Ok(result) => return Ok(result),
7691                Err(err) if req.model.is_some() => return Err(err),
7692                Err(err) => last_error = Some(err),
7693            }
7694        }
7695
7696        Err(last_error.unwrap_or_else(|| {
7697            InferenceError::InferenceFailed("no video generation models available".into())
7698        }))
7699    }
7700
7701    /// List all known models and their status (new registry).
7702    /// Why `model` cannot honor a `response_format`, or `None` when it can
7703    /// (or is unknown here). Asks the SAME protocol handler the remote path
7704    /// consults (`ProtocolHandler::supports_response_format`) so a CLI can
7705    /// warn before a run rather than discover the `UnsupportedMode` on the
7706    /// repair turn. The Parslee gateway rejects every format separately in
7707    /// `execute_request`, so it is named here too.
7708    pub fn response_format_rejection_reason(
7709        &self,
7710        model: &str,
7711        rf: &crate::tasks::generate::ResponseFormat,
7712    ) -> Option<String> {
7713        let registry = self.unified_registry.clone();
7714        let schema = registry
7715            .list()
7716            .into_iter()
7717            .find(|s| s.id == model || s.name == model)?;
7718        match &schema.source {
7719            ModelSource::Proprietary { .. } => {
7720                Some("the Parslee gateway does not accept response_format".to_string())
7721            }
7722            ModelSource::RemoteApi { protocol, .. } => {
7723                let handler = crate::protocol::handler_for(*protocol);
7724                (!handler.supports_response_format(rf)).then(|| {
7725                    format!(
7726                        "the {} protocol rejects response_format",
7727                        handler.protocol_name()
7728                    )
7729                })
7730            }
7731            _ => None,
7732        }
7733    }
7734
7735    /// The unified catalog, annotated for the machine this engine runs on
7736    /// under its active local-model resource policy — the same policy
7737    /// `models.preflight` admits against, kept in step with the persisted
7738    /// one by `apply_local_resource_policy`.
7739    pub fn list_models_unified(&self) -> Vec<ModelInfo> {
7740        let hardware = HardwareInfo::detect();
7741        let policy = self.active_local_resource_policy();
7742        self.list_models_unified_for(&hardware, &policy.policy)
7743    }
7744
7745    /// The fit annotation for one schema on this machine under the active
7746    /// policy — `list_models_unified`'s verdict for a row built elsewhere
7747    /// (`models.search`), so every catalog surface publishes the same one.
7748    pub fn model_fit(&self, schema: &ModelSchema) -> recommend::ModelFit {
7749        recommend::model_fit(
7750            schema,
7751            &HardwareInfo::detect(),
7752            Some(&self.active_local_resource_policy().policy),
7753        )
7754    }
7755
7756    /// [`Self::list_models_unified`] against explicit hardware and policy.
7757    /// Every row is returned in registry order with every existing field
7758    /// unchanged; the fit annotation is computed per call and never stored.
7759    pub fn list_models_unified_for(
7760        &self,
7761        hardware: &HardwareInfo,
7762        policy: &resource_policy::ResourcePolicy,
7763    ) -> Vec<ModelInfo> {
7764        let mut registry = self.unified_registry.clone();
7765        registry.refresh_availability();
7766        registry
7767            .list()
7768            .iter()
7769            .map(|schema| {
7770                let mut info = ModelInfo::from(*schema).with_fit(recommend::model_fit(
7771                    schema,
7772                    hardware,
7773                    Some(policy),
7774                ));
7775                if schema.downloads_weights() {
7776                    let enabled = self
7777                        .model_management
7778                        .car_enabled(&schema.id)
7779                        .unwrap_or(false);
7780                    if !enabled {
7781                        // `weights_ready` remains a physical diagnostic, but
7782                        // legacy clients route on `available`. Never advertise
7783                        // a tombstoned local model as routeable.
7784                        info.available = false;
7785                    }
7786                    if enabled
7787                        && self
7788                            .model_management
7789                            .load_receipt(&schema.id)
7790                            .is_ok_and(|receipt| receipt.is_none())
7791                    {
7792                        // Legacy CAR installs sometimes left only a managed
7793                        // symlink. That is safe to adopt automatically because
7794                        // deletion remains limited to the link and the exact
7795                        // shared target is captured in the receipt. Plain
7796                        // directories and shared-cache-only artifacts stay
7797                        // usable but unowned until explicit adoption.
7798                        if let Ok(_mutation) = self.model_management.begin_mutation(&schema.id) {
7799                            if let Some(path) = registry.existing_local_artifact(&schema.id) {
7800                                if std::fs::symlink_metadata(&path)
7801                                    .is_ok_and(|metadata| metadata.file_type().is_symlink())
7802                                {
7803                                    let generation = self
7804                                        .resource_policy_generation
7805                                        .load(std::sync::atomic::Ordering::Acquire);
7806                                    let _ = self.model_management.record_managed_artifact(
7807                                        &schema.id,
7808                                        model_source_identity(schema),
7809                                        None,
7810                                        generation,
7811                                        true,
7812                                        path,
7813                                    );
7814                                }
7815                            }
7816                        }
7817                    }
7818                    let receipt = self.model_management.load_receipt(&schema.id);
7819                    let directory_cleanup_unsupported =
7820                        !model_management::directory_removal_supported()
7821                            && receipt.as_ref().is_ok_and(|receipt| {
7822                                receipt.as_ref().is_some_and(|receipt| {
7823                                    receipt.artifact_kind
7824                                        == model_management::ManagedArtifactKind::Directory
7825                                })
7826                            });
7827                    info.car_enabled = enabled;
7828                    info.can_remove = enabled
7829                        && self
7830                            .model_management
7831                            .can_remove(&schema.id)
7832                            .unwrap_or(false);
7833                    info.in_use = self.local_admission.active_request_count(&schema.id) > 0
7834                        || self.local_admission.is_resident(&schema.id)
7835                        || self.local_admission.teardown_pending(&schema.id)
7836                        || self
7837                            .model_management
7838                            .model_in_use(&schema.id)
7839                            .unwrap_or(true);
7840                    info.management_evidence = if !enabled {
7841                        Some("disabled_tombstone".into())
7842                    } else if directory_cleanup_unsupported {
7843                        Some("install_receipt_directory_cleanup_unsupported".into())
7844                    } else if receipt.is_ok_and(|receipt| receipt.is_some()) {
7845                        Some("install_receipt".into())
7846                    } else if info.weights_ready {
7847                        Some("shared_or_hand_installed".into())
7848                    } else {
7849                        None
7850                    };
7851                }
7852                info
7853            })
7854            .collect()
7855    }
7856
7857    pub fn model_management_store(&self) -> &model_management::ModelManagementStore {
7858        &self.model_management
7859    }
7860
7861    fn ensure_model_enabled(&self, model_id: &str) -> Result<(), InferenceError> {
7862        if self.model_management.car_enabled(model_id)? {
7863            return Ok(());
7864        }
7865        Err(InferenceError::InferenceFailed(format!(
7866            "local model {model_id} was removed from CAR; reinstall it before use"
7867        )))
7868    }
7869
7870    /// Report installed models that have curated newer replacements.
7871    pub fn available_model_upgrades(&self) -> Vec<ModelUpgrade> {
7872        self.unified_registry.available_upgrades()
7873    }
7874
7875    /// The proactive-upgrade decision for right now: which curated upgrades to
7876    /// auto-apply (under `Auto` policy) and the single nudge to surface, with
7877    /// throttling and dismissals applied. The daemon calls this on its periodic
7878    /// check and broadcasts `decision.nudge` over WebSocket. Returns the loaded
7879    /// `NudgeState` too so the caller can stamp `last_nudge_secs` after sending.
7880    pub async fn check_upgrade_nudge(
7881        &self,
7882        inference_active: bool,
7883    ) -> (crate::nudge::NudgeDecision, crate::nudge::NudgeState) {
7884        let findings = self.detect_upgrades().await;
7885        let prefs = self.update_prefs();
7886        let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
7887        let now = std::time::SystemTime::now()
7888            .duration_since(std::time::UNIX_EPOCH)
7889            .map(|d| d.as_secs())
7890            .unwrap_or(0);
7891        let decision = crate::nudge::decide_nudge(
7892            &findings,
7893            &prefs,
7894            &state,
7895            now,
7896            crate::nudge::DEFAULT_THROTTLE_SECS,
7897            inference_active,
7898        );
7899        (decision, state)
7900    }
7901
7902    /// Record that the user dismissed a nudge (by its `dismiss_key`), so it is
7903    /// never surfaced again. Persists to `~/.car/nudge-state.json`.
7904    pub fn dismiss_upgrade_nudge(&self, dismiss_key: &str) -> Result<(), InferenceError> {
7905        let path = crate::nudge::NudgeState::default_path();
7906        let mut state = crate::nudge::NudgeState::load_from(&path);
7907        state.dismiss(dismiss_key);
7908        state
7909            .save_to(&path)
7910            .map_err(InferenceError::InferenceFailed)
7911    }
7912
7913    /// Run the proactive concierge decision: for the default watched lanes,
7914    /// suggest a model to acquire for any lane the user has nothing installed
7915    /// for. Returns the suggestions plus the loaded [`NudgeState`] so the caller
7916    /// can stamp `last_concierge_secs` after surfacing (mirrors the
7917    /// stamp-after-deliver pattern of [`Self::check_upgrade_nudge`]). The
7918    /// concierge throttles independently of the upgrade nudge.
7919    pub async fn check_concierge(
7920        &self,
7921        inference_active: bool,
7922    ) -> (
7923        Vec<crate::concierge::ConciergeSuggestion>,
7924        crate::nudge::NudgeState,
7925    ) {
7926        let prefs = self.update_prefs();
7927        let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
7928        let hw = crate::hardware::HardwareInfo::detect();
7929        let schemas = self.list_schemas();
7930        let refs: Vec<&ModelSchema> = schemas.iter().collect();
7931        let now = std::time::SystemTime::now()
7932            .duration_since(std::time::UNIX_EPOCH)
7933            .map(|d| d.as_secs())
7934            .unwrap_or(0);
7935        let suggestions = crate::concierge::decide_concierge(
7936            &refs,
7937            &hw,
7938            crate::concierge::DEFAULT_WATCHED_USE_CASES,
7939            crate::intent::QualityTier::Balanced,
7940            &prefs,
7941            &state,
7942            now,
7943            crate::concierge::DEFAULT_CONCIERGE_THROTTLE_SECS,
7944            inference_active,
7945        );
7946        (suggestions, state)
7947    }
7948
7949    /// Record that the user dismissed a concierge suggestion (by its
7950    /// `dismiss_key`), so it is never surfaced again. Shares the same
7951    /// `~/.car/nudge-state.json` `dismissed` list as the upgrade nudge — the key
7952    /// namespaces are disjoint (`concierge:…` vs `from=>to`).
7953    pub fn dismiss_concierge_suggestion(&self, dismiss_key: &str) -> Result<(), InferenceError> {
7954        self.dismiss_upgrade_nudge(dismiss_key)
7955    }
7956
7957    /// Record a *labeled* concierge dismissal (Phase B4/C1) so the Act
7958    /// gate can treat the reason as signal (permanent reasons suppress;
7959    /// `NotNow` cools down). Persists to `~/.car/nudge-state.json`.
7960    pub fn dismiss_concierge_labeled(
7961        &self,
7962        dismiss_key: &str,
7963        reason: crate::concierge::DismissReason,
7964    ) -> Result<(), String> {
7965        let path = crate::nudge::NudgeState::default_path();
7966        let mut state = crate::nudge::NudgeState::load_from(&path);
7967        let now = std::time::SystemTime::now()
7968            .duration_since(std::time::UNIX_EPOCH)
7969            .map(|d| d.as_secs())
7970            .unwrap_or(0);
7971        state.dismiss_labeled(dismiss_key, reason, now);
7972        state.save_to(&path).map_err(|e| e.to_string())
7973    }
7974
7975    /// Net-positive verification (Phase F3): for each lane whose latest
7976    /// action was a `SetDefault` (a switch not yet rolled back), compare
7977    /// the new model's *observed* post-switch success in that lane against
7978    /// the prior model's baseline; if it's measurably worse with enough
7979    /// samples, **auto-revert** to the prior. Never self-graded — the
7980    /// signal is the outcome ledger's verifier/outcome receipts. The
7981    /// daemon calls this on its periodic tick. Returns the reverted lanes.
7982    pub async fn check_canaries(&self) -> Vec<crate::intent::UseCase> {
7983        use crate::action_ledger::ConciergeActionKind;
7984        use crate::concierge::{
7985            canary_verdict, CanaryVerdict, CANARY_MIN_SAMPLES, CANARY_REGRESSION_MARGIN,
7986        };
7987        use std::collections::BTreeMap;
7988
7989        let actions = self.concierge_actions(0);
7990        let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
7991        let entries = crate::outcome::read_ledger(&ledger_path, 0);
7992
7993        // Latest action per global (project=None) lane — append order, last wins.
7994        let mut latest: BTreeMap<
7995            crate::intent::UseCase,
7996            &crate::action_ledger::ConciergeActionEntry,
7997        > = BTreeMap::new();
7998        for a in &actions {
7999            if a.project.is_some() {
8000                continue; // only global lanes canaried for now
8001            }
8002            if let Some(uc) = a.use_case {
8003                latest.insert(uc, a);
8004            }
8005        }
8006
8007        // Decide first (under the tracker read lock), then execute reverts
8008        // after releasing it — rollback acquires the action lock and must
8009        // not nest under the tracker lock.
8010        // (lane, anchor seq) — seq uniquely identifies the standing switch.
8011        let mut reverts: Vec<(crate::intent::UseCase, u64)> = Vec::new();
8012        let tracker = self.outcome_tracker.read().await;
8013        for (uc, a) in latest {
8014            // Only a standing switch (not already rolled back) with a prior
8015            // to fall back to is a canary candidate.
8016            if a.kind != ConciergeActionKind::SetDefault {
8017                continue;
8018            }
8019            let Some(prior) = a.prior_model_id.as_deref() else {
8020                continue; // no baseline → nothing to compare/revert to
8021            };
8022
8023            // New model's post-switch resolved success in this lane.
8024            let (mut succ, mut total) = (0u64, 0u64);
8025            for e in &entries {
8026                if e.timestamp < a.timestamp
8027                    || e.model_id != a.model_id
8028                    || crate::usage_profile::use_case_for_task(e.task) != uc
8029                {
8030                    continue;
8031                }
8032                match e.success {
8033                    Some(true) => {
8034                        succ += 1;
8035                        total += 1;
8036                    }
8037                    Some(false) => total += 1,
8038                    None => {}
8039                }
8040            }
8041            let new_rate = if total == 0 {
8042                None
8043            } else {
8044                Some(succ as f64 / total as f64)
8045            };
8046
8047            // Baseline must be LANE-SCOPED to compare like-for-like: sum the
8048            // prior model's per-task stats across tasks that map to THIS
8049            // lane (not its global lifetime success rate, which mixes other
8050            // lanes and would revert good switches / keep bad ones).
8051            let (mut base_succ, mut base_total) = (0u64, 0u64);
8052            if let Some(profile) = tracker.profile(prior) {
8053                for t in [
8054                    crate::outcome::InferenceTask::Generate,
8055                    crate::outcome::InferenceTask::Embed,
8056                    crate::outcome::InferenceTask::Classify,
8057                    crate::outcome::InferenceTask::Code,
8058                    crate::outcome::InferenceTask::Reasoning,
8059                ] {
8060                    if crate::usage_profile::use_case_for_task(t) != uc {
8061                        continue;
8062                    }
8063                    if let Some(ts) = profile.task_stats(t) {
8064                        base_succ += ts.successes;
8065                        base_total += ts.successes + ts.failures;
8066                    }
8067                }
8068            }
8069            // No real lane baseline for the prior → never auto-revert (don't
8070            // revert against a made-up neutral prior).
8071            if base_total == 0 {
8072                continue;
8073            }
8074            let baseline = base_succ as f64 / base_total as f64;
8075
8076            if canary_verdict(
8077                new_rate,
8078                total,
8079                baseline,
8080                CANARY_MIN_SAMPLES,
8081                CANARY_REGRESSION_MARGIN,
8082            ) == CanaryVerdict::Revert
8083            {
8084                // Conditional revert under the action lock: only undo if
8085                // this exact switch is still the standing one (the user may
8086                // have applied a newer one since we read). Atomic vs. apply.
8087                reverts.push((uc, a.seq));
8088            }
8089        }
8090        drop(tracker);
8091
8092        // Execute the reverts: each is conditional on its anchor still being
8093        // the standing switch (rollback_lane_inner re-checks under the lock).
8094        let mut reverted = Vec::new();
8095        for (uc, seq) in reverts {
8096            if self.rollback_lane_inner(uc, None, Some(seq)).await.is_ok() {
8097                tracing::info!(lane = ?uc, "concierge canary: auto-reverted a worse model switch");
8098                reverted.push(uc);
8099            }
8100        }
8101        reverted
8102    }
8103
8104    /// Conversational concierge (Phase F1/F2): answer a free-form
8105    /// question about the user's models/portfolio, grounded in the
8106    /// observed-usage evidence + the deterministic `recommend()` candidate
8107    /// menu. The LLM *explains* — it runs on a local model, is told to
8108    /// answer ONLY from the supplied evidence, and must not invent a model
8109    /// or assert fit (the grounding oracle already decided fit). This is
8110    /// the ModelConcierge "agent": a thin, constrained `generate` call
8111    /// over assembled receipts, not a freelancing chat.
8112    pub async fn concierge_ask(&self, question: &str) -> Result<String, String> {
8113        use std::fmt::Write as _;
8114        let status = self.concierge_status(false).await;
8115        let hw = crate::hardware::HardwareInfo::detect();
8116        let schemas = self.list_schemas();
8117        let refs: Vec<&ModelSchema> = schemas.iter().collect();
8118
8119        let mut evidence = String::new();
8120        evidence.push_str("OBSERVED USAGE (last 30 days):\n");
8121        if status.lanes.is_empty() {
8122            evidence.push_str("  (no usage recorded yet)\n");
8123        }
8124        for lane in &status.lanes {
8125            let rate = lane
8126                .success_rate()
8127                .map(|r| format!("{:.0}% success", r * 100.0))
8128                .unwrap_or_else(|| "no resolved signal".into());
8129            let _ = writeln!(
8130                evidence,
8131                "  {:?}: {} calls, {}{}",
8132                lane.use_case,
8133                lane.calls,
8134                rate,
8135                if lane.failing_models.is_empty() {
8136                    String::new()
8137                } else {
8138                    format!(
8139                        ", failing on {}",
8140                        lane.failing_models
8141                            .iter()
8142                            .cloned()
8143                            .collect::<Vec<_>>()
8144                            .join(", ")
8145                    )
8146                }
8147            );
8148        }
8149        evidence.push_str("\nMODEL HEALTH:\n");
8150        for m in &status.models {
8151            let success = match m.success_rate {
8152                Some(r) => format!("{:.0}% success", r * 100.0),
8153                None => "no resolved signal".to_string(),
8154            };
8155            let _ = writeln!(
8156                evidence,
8157                "  {}: {} calls, {}, {:.0}ms avg{}",
8158                m.model_id,
8159                m.calls,
8160                success,
8161                m.avg_latency_ms,
8162                if m.excluded { " (excluded)" } else { "" }
8163            );
8164        }
8165        // Grounded candidate menu — the ONLY models the answer may
8166        // reference (with their real fit on this machine). Cover the
8167        // default-watched lanes PLUS every lane the user actually uses, so
8168        // a question about vision/transcription/search has grounded
8169        // candidates instead of forcing the model to improvise.
8170        let mut menu_lanes: Vec<crate::intent::UseCase> =
8171            crate::concierge::DEFAULT_WATCHED_USE_CASES.to_vec();
8172        for lane in &status.lanes {
8173            if !menu_lanes.contains(&lane.use_case) {
8174                menu_lanes.push(lane.use_case);
8175            }
8176        }
8177        evidence.push_str("\nGROUNDED CANDIDATES (fit verified for this machine):\n");
8178        for uc in menu_lanes {
8179            let set = crate::recommend::recommend(
8180                &refs,
8181                &hw,
8182                uc,
8183                crate::intent::QualityTier::Balanced,
8184                crate::intent::Privacy::OnDevice,
8185            );
8186            for p in set.picks.iter().take(3) {
8187                let _ = writeln!(
8188                    evidence,
8189                    "  [{:?}] {} — {}{}",
8190                    uc,
8191                    p.display_name,
8192                    if p.already_installed {
8193                        "installed"
8194                    } else {
8195                        "available"
8196                    },
8197                    if p.fit == crate::recommend::FitStatus::Fits {
8198                        ", fits"
8199                    } else {
8200                        ", does NOT fit"
8201                    }
8202                );
8203            }
8204        }
8205        if let Some(s) = &status.decision.suggestion {
8206            let _ = writeln!(evidence, "\nCURRENT SUGGESTION: {}", s.message);
8207        }
8208
8209        let prompt = format!(
8210            "You are CAR's model concierge. Answer the user's question ONLY from the \
8211             EVIDENCE provided as context — the user's observed model usage, model \
8212             health, and the grounded candidate menu (the only models you may \
8213             mention). NEVER invent a model name and NEVER claim a model fits or is \
8214             better than the evidence states. If the evidence doesn't answer the \
8215             question, say so plainly. Be concise and concrete.\n\nQUESTION: {question}"
8216        );
8217        let evidence_lc = evidence.to_lowercase();
8218        let req = crate::tasks::generate::GenerateRequest {
8219            prompt,
8220            context: Some(evidence),
8221            intent: Some(crate::intent::IntentHint {
8222                task: Some(crate::intent::TaskHint::Chat),
8223                prefer_local: true,
8224                ..Default::default()
8225            }),
8226            ..Default::default()
8227        };
8228        let answer = self.generate(req).await.map_err(|e| e.to_string())?;
8229
8230        // Soft grounding guard: a local model may still name a model family
8231        // outside the evidence. Don't strip mid-sentence (garbles output) —
8232        // flag it, so a hallucinated recommendation can't pass as verified.
8233        const FAMILIES: [&str; 9] = [
8234            "llama", "gpt", "mistral", "gemma", "deepseek", "phi", "claude", "grok", "qwen",
8235        ];
8236        let answer_lc = answer.to_lowercase();
8237        let leaked = FAMILIES
8238            .iter()
8239            .any(|fam| answer_lc.contains(fam) && !evidence_lc.contains(fam));
8240        let answer = if leaked {
8241            format!(
8242                "{answer}\n\n(Note: I can only verify models in your catalog — any others \
8243                 named above aren't checked for fit on your machine.)"
8244            )
8245        } else {
8246            answer
8247        };
8248        Ok(answer)
8249    }
8250
8251    /// Refresh the model catalog from the configured signed source
8252    /// (Phase E1): fetch + verify (detached ed25519 against the pinned
8253    /// key) + cache the verified models. Source is `CAR_CATALOG_URL` +
8254    /// `CAR_CATALOG_PUBKEY` (no key → refused). The new models load into
8255    /// the registry at next startup (the registry is immutable at
8256    /// runtime), then surface as `recommend()` candidates / concierge
8257    /// suggestions. Returns the number of models in the verified catalog.
8258    pub async fn refresh_catalog(&self) -> Result<usize, String> {
8259        let url = std::env::var("CAR_CATALOG_URL")
8260            .map_err(|_| "no catalog source configured (set CAR_CATALOG_URL)".to_string())?;
8261        let pubkey = std::env::var("CAR_CATALOG_PUBKEY")
8262            .map_err(|_| "no catalog public key configured (set CAR_CATALOG_PUBKEY)".to_string())?;
8263        // Not `Client::new()`: that is `build().expect(..)`, which panics when
8264        // the OS trust store loads zero valid certificates. Degrading here is
8265        // safe even for a privately-hosted catalog — authenticity comes from
8266        // the detached ed25519 signature checked below, not from TLS.
8267        let http = crate::tls_client::catalog_refresh_client();
8268        let verified = crate::catalog::fetch_and_verify(&http, &url, &pubkey).await?;
8269        let path = crate::catalog::cache_path(&self.config.state_root);
8270        // Signature verification alone proves authenticity, not freshness.
8271        // Compare the authenticated cached version and atomically replace it
8272        // under one process-local lock so concurrent N/N+1 refreshes cannot
8273        // commit the lower version last.
8274        crate::catalog::install_if_newer(&path, &verified, &pubkey).await
8275    }
8276
8277    /// Auto-discover provider models (Phase E2): query the provider's
8278    /// `/v1/models` list and cache previously-unknown chat/reasoning models as
8279    /// `TrustTier::Community` entries (cloning a curated same-provider schema as
8280    /// a template). Best-effort — no key or no OpenAI provider configured is
8281    /// a no-op, not an error. Discovered models load into the registry at next
8282    /// startup (the registry is immutable at runtime). Returns the total number
8283    /// of cached discovered models. This is what lets the catalog (and the
8284    /// router) pick up new models like a `gpt-5.5` without a release.
8285    pub async fn discover_models(&self) -> Result<usize, String> {
8286        use crate::schema::{ModelSource, TrustTier};
8287        // Template = a curated, remote OpenAI model — gives the endpoint, key
8288        // env, protocol, and routing metadata new entries inherit. `all()` is
8289        // HashMap-ordered (non-deterministic), so pick the MOST-CAPABLE curated
8290        // OpenAI remote (tie-break by id for determinism) rather than the first
8291        // one — otherwise a discovered model could inherit a reduced-capability
8292        // entry like `-mini`.
8293        let template = self
8294            .unified_registry
8295            .all()
8296            .filter(|m| {
8297                m.provider.eq_ignore_ascii_case("openai")
8298                    && m.trust_tier == TrustTier::Curated
8299                    && matches!(m.source, ModelSource::RemoteApi { .. })
8300            })
8301            .max_by(|a, b| {
8302                a.capabilities
8303                    .len()
8304                    .cmp(&b.capabilities.len())
8305                    .then_with(|| a.id.cmp(&b.id))
8306            })
8307            .cloned();
8308        let Some(template) = template else {
8309            return Ok(0); // no OpenAI provider configured → nothing to discover
8310        };
8311        let (endpoint, api_key_env) = match &template.source {
8312            ModelSource::RemoteApi {
8313                endpoint,
8314                api_key_env,
8315                ..
8316            } => (endpoint.clone(), api_key_env.clone()),
8317            _ => return Ok(0),
8318        };
8319        let Some(models_url) = crate::discovery::models_url_from_endpoint(&endpoint) else {
8320            return Ok(0);
8321        };
8322        let key = match car_secrets::resolve_env_or_keychain(&api_key_env) {
8323            Some(k) if !k.is_empty() => k,
8324            _ => return Ok(0), // no key (env or keychain) → best-effort skip
8325        };
8326
8327        // Bounded HTTP: this runs on a background daily timer, so a hung
8328        // provider endpoint must not wedge the loop (which is
8329        // `discover(); sleep(24h)` — a stuck await never reaches the sleep).
8330        let http = reqwest::Client::builder()
8331            .timeout(std::time::Duration::from_secs(20))
8332            .build()
8333            .map_err(|e| format!("discovery client: {e}"))?;
8334        let ids = crate::discovery::fetch_model_ids(&http, &models_url, &key).await?;
8335
8336        // Merge new finds with anything already cached (don't drop prior runs).
8337        let cache = crate::discovery::cache_path(&self.config.state_models_dir());
8338        let mut cached = crate::discovery::load_cache(&cache);
8339        let mut have: std::collections::HashSet<String> =
8340            cached.iter().map(|m| m.id.clone()).collect();
8341        for id in ids {
8342            if !crate::discovery::is_chat_model(&id) {
8343                continue;
8344            }
8345            let schema = crate::discovery::discovered_schema("openai", &id, &template);
8346            // Dedup by the constructed id against BOTH the live registry
8347            // (curated + signed + already-loaded discovered) and this run's
8348            // cache — discovery only ever ADDS ids nothing else owns. Keying on
8349            // the id is more robust than matching the provider's bare id
8350            // against curated `name`s.
8351            if self.unified_registry.get(&schema.id).is_some() {
8352                continue;
8353            }
8354            if have.insert(schema.id.clone()) {
8355                cached.push(schema);
8356            }
8357        }
8358        let count = cached.len();
8359        crate::discovery::save_cache(&cache, &cached)?;
8360        Ok(count)
8361    }
8362
8363    /// All configured lane defaults (Phase D1), from the in-memory cache.
8364    pub fn lane_defaults(&self) -> crate::lane_defaults::LaneDefaults {
8365        self.lane_defaults_cache.read().unwrap().clone()
8366    }
8367
8368    /// Resolve the default model for `(project, use_case)`, if set.
8369    /// Routing consults this as a strong preference before falling back
8370    /// to adaptive selection. Reads the cache (no disk).
8371    pub fn lane_default(
8372        &self,
8373        project: Option<&str>,
8374        use_case: crate::intent::UseCase,
8375    ) -> Option<String> {
8376        self.lane_defaults_cache
8377            .read()
8378            .unwrap()
8379            .resolve(project, use_case)
8380            .map(str::to_string)
8381    }
8382
8383    fn model_is_excluded(
8384        exclude_set: &std::collections::HashSet<String>,
8385        registry: &UnifiedRegistry,
8386        model: &str,
8387    ) -> bool {
8388        registry
8389            .get(model)
8390            .or_else(|| registry.find_by_name(model))
8391            .map(|schema| exclude_set.contains(&schema.id))
8392            .unwrap_or_else(|| exclude_set.contains(model))
8393    }
8394
8395    /// The lane-default model to honor for a request when the caller
8396    /// didn't pin one — `None` unless the request carries a use-case
8397    /// intent whose lane default resolves to a known, available model.
8398    /// A stale/uninstalled pin returns `None` so routing falls through to
8399    /// adaptive selection rather than wedging on a missing model.
8400    fn lane_pin_for(
8401        &self,
8402        req: &GenerateRequest,
8403        routing_registry: &UnifiedRegistry,
8404    ) -> Option<String> {
8405        let task = req.intent.as_ref().and_then(|h| h.task)?;
8406        let use_case = crate::usage_profile::use_case_for_task_hint(task);
8407        let id = self.lane_default(None, use_case)?;
8408        // Validate against the same per-request snapshot the adaptive router
8409        // and dispatch path consume. That snapshot refreshes live credential
8410        // availability for the fixed reviewed registry, so key changes apply
8411        // without allowing an unregistered lane id through.
8412        let known = routing_registry
8413            .get(&id)
8414            .or_else(|| routing_registry.find_by_name(&id));
8415        match known {
8416            Some(s) if s.available_now() => Some(id),
8417            _ => None,
8418        }
8419    }
8420
8421    /// Set the default model for `(project, use_case)` (Phase D1) — the
8422    /// durable target of the concierge's "set it up" action.
8423    pub fn set_lane_default(
8424        &self,
8425        project: Option<String>,
8426        use_case: crate::intent::UseCase,
8427        model_id: &str,
8428    ) -> Result<(), String> {
8429        let now = std::time::SystemTime::now()
8430            .duration_since(std::time::UNIX_EPOCH)
8431            .map(|d| d.as_secs())
8432            .unwrap_or(0);
8433        // Update cache (hot path reads it) + persist, under the cache lock
8434        // so the two stay consistent for the single-writer concierge.
8435        let mut defaults = self.lane_defaults_cache.write().unwrap();
8436        defaults.set(project, use_case, model_id.to_string(), now);
8437        crate::lane_defaults::save_to(&crate::lane_defaults::default_path(), &defaults)
8438            .map_err(|e| e.to_string())
8439    }
8440
8441    /// Clear the default for `(project, use_case)`. Returns whether one
8442    /// existed (used by rollback in D3).
8443    pub fn clear_lane_default(
8444        &self,
8445        project: Option<&str>,
8446        use_case: crate::intent::UseCase,
8447    ) -> Result<bool, String> {
8448        let mut defaults = self.lane_defaults_cache.write().unwrap();
8449        let removed = defaults.clear(project, use_case);
8450        crate::lane_defaults::save_to(&crate::lane_defaults::default_path(), &defaults)
8451            .map_err(|e| e.to_string())?;
8452        Ok(removed)
8453    }
8454
8455    /// User-facing lane-default set (the `concierge.set_default` WS path):
8456    /// like [`set_lane_default`](crate::InferenceEngine::set_lane_default) but serialized under the concierge action
8457    /// lock AND recorded in the action ledger. Without the ledger entry a
8458    /// manual pin would be invisible to the canary, which could then revert
8459    /// it based on a stale ledger view — so this records a `SetDefault`
8460    /// (with the prior captured) exactly like `apply`, keeping the ledger
8461    /// and the live default consistent.
8462    pub async fn user_set_lane_default(
8463        &self,
8464        use_case: crate::intent::UseCase,
8465        model_id: &str,
8466        project: Option<String>,
8467    ) -> Result<(), String> {
8468        use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8469        let _guard = self.concierge_action_lock.lock().await;
8470        let prior = self.lane_default(project.as_deref(), use_case);
8471        self.set_lane_default(project.clone(), use_case, model_id)?;
8472        self.record_concierge_action(ConciergeActionEntry {
8473            seq: 0,
8474            kind: ConciergeActionKind::SetDefault,
8475            model_id: model_id.to_string(),
8476            use_case: Some(use_case),
8477            project,
8478            prior_model_id: prior,
8479            detail: "user set lane default".into(),
8480            timestamp: now_unix(),
8481        });
8482        Ok(())
8483    }
8484
8485    /// User-facing lane-default clear (the `concierge.clear_default` WS
8486    /// path): serialized + ledgered like [`user_set_lane_default`](crate::InferenceEngine::user_set_lane_default). Records
8487    /// a `ClearDefault` so the canary sees the lane is no longer a standing
8488    /// switch (its `latest` action is the clear, not a `SetDefault`).
8489    pub async fn user_clear_lane_default(
8490        &self,
8491        use_case: crate::intent::UseCase,
8492        project: Option<String>,
8493    ) -> Result<bool, String> {
8494        use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8495        let _guard = self.concierge_action_lock.lock().await;
8496        let prior = self.lane_default(project.as_deref(), use_case);
8497        let removed = self.clear_lane_default(project.as_deref(), use_case)?;
8498        if removed {
8499            self.record_concierge_action(ConciergeActionEntry {
8500                seq: 0,
8501                kind: ConciergeActionKind::ClearDefault,
8502                model_id: prior.clone().unwrap_or_default(),
8503                use_case: Some(use_case),
8504                project,
8505                prior_model_id: prior,
8506                detail: "user cleared lane default".into(),
8507                timestamp: now_unix(),
8508            });
8509        }
8510        Ok(removed)
8511    }
8512
8513    /// The recorded concierge actions (Phase D2), most recent last.
8514    pub fn concierge_actions(
8515        &self,
8516        limit: usize,
8517    ) -> Vec<crate::action_ledger::ConciergeActionEntry> {
8518        crate::action_ledger::read_actions(&crate::action_ledger::default_path(), limit)
8519    }
8520
8521    fn record_concierge_action(&self, mut entry: crate::action_ledger::ConciergeActionEntry) {
8522        entry.seq = self
8523            .concierge_action_seq
8524            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8525        if let Err(e) =
8526            crate::action_ledger::append_action(&crate::action_ledger::default_path(), &entry)
8527        {
8528            tracing::debug!("record concierge action failed: {e}");
8529        }
8530    }
8531
8532    /// Closed-loop "set it up" (Phase D3): acquire `model_id`, then set it
8533    /// as the lane default — capturing the prior default so the change is
8534    /// reversible ([`rollback_lane`](crate::InferenceEngine::rollback_lane)). Every step is recorded in the
8535    /// action ledger.
8536    ///
8537    /// Consent: the caller (CarHost) owns the pre-download confirmation —
8538    /// this primitive assumes the user has already agreed to the (possibly
8539    /// multi-GB) download; the ledger entry is the audit record that it
8540    /// happened. Single-writer: lane defaults assume one concierge writer
8541    /// (CarHost); concurrent `apply`s would last-write-wins the JSON (the
8542    /// F3 canary watcher must coordinate before it becomes a 2nd writer).
8543    pub async fn apply_concierge(
8544        &self,
8545        use_case: crate::intent::UseCase,
8546        model_id: &str,
8547        project: Option<String>,
8548    ) -> Result<crate::action_ledger::ConciergeApplyResult, String> {
8549        use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8550        let now = || {
8551            std::time::SystemTime::now()
8552                .duration_since(std::time::UNIX_EPOCH)
8553                .map(|d| d.as_secs())
8554                .unwrap_or(0)
8555        };
8556        // Lane-fit guard: refuse to pin a model that structurally can't
8557        // serve the lane (e.g. a vision-only model on the coding lane).
8558        // A model id unknown to the registry is left to `pull_model` to
8559        // reject as not-found.
8560        if let Some(schema) = self
8561            .list_schemas()
8562            .into_iter()
8563            .find(|s| s.id == model_id || s.name == model_id)
8564        {
8565            let serves = use_case
8566                .required_capabilities()
8567                .iter()
8568                .all(|c| schema.capabilities.contains(c));
8569            if !serves {
8570                return Err(format!(
8571                    "model '{model_id}' does not serve the {use_case:?} lane"
8572                ));
8573            }
8574        }
8575
8576        // 1. Acquire (idempotent — `ensure_local` skips an already-present
8577        //    model; the Install record means "ensured present", not
8578        //    necessarily a fresh download). OUTSIDE the action lock so a
8579        //    long download doesn't block the canary tick.
8580        self.pull_model(model_id).await.map_err(|e| e.to_string())?;
8581
8582        // 2+3 under the action lock: capture prior + record + set default
8583        //    atomically vs. a concurrent canary revert.
8584        let _guard = self.concierge_action_lock.lock().await;
8585        // Capture what we're replacing AFTER taking the lock, so a canary
8586        // revert can't slip in between the read and the write.
8587        let prior = self.lane_default(project.as_deref(), use_case);
8588        self.record_concierge_action(ConciergeActionEntry {
8589            seq: 0, // assigned by record_concierge_action
8590            kind: ConciergeActionKind::Install,
8591            model_id: model_id.to_string(),
8592            use_case: Some(use_case),
8593            project: project.clone(),
8594            prior_model_id: None,
8595            detail: "concierge apply: ensured model present".into(),
8596            timestamp: now(),
8597        });
8598
8599        // 2. Set as the lane default (reversible — prior captured).
8600        self.set_lane_default(project.clone(), use_case, model_id)?;
8601        self.record_concierge_action(ConciergeActionEntry {
8602            seq: 0, // assigned by record_concierge_action
8603            kind: ConciergeActionKind::SetDefault,
8604            model_id: model_id.to_string(),
8605            use_case: Some(use_case),
8606            project: project.clone(),
8607            prior_model_id: prior.clone(),
8608            detail: "concierge apply: set lane default".into(),
8609            timestamp: now(),
8610        });
8611
8612        Ok(crate::action_ledger::ConciergeApplyResult {
8613            model_id: model_id.to_string(),
8614            use_case,
8615            installed: true,
8616            set_default: true,
8617            prior_model_id: prior,
8618        })
8619    }
8620
8621    /// Revert a lane default to its value before the last `apply` (Phase
8622    /// D3 rollback). Restores the prior model (or clears the default if
8623    /// there was none), recording the rollback. Returns the restored
8624    /// model id, or `None` if the default was cleared / nothing to undo.
8625    pub async fn rollback_lane(
8626        &self,
8627        use_case: crate::intent::UseCase,
8628        project: Option<String>,
8629    ) -> Result<Option<String>, String> {
8630        self.rollback_lane_inner(use_case, project, None).await
8631    }
8632
8633    /// Inner rollback: serialized under the concierge action lock so the
8634    /// anchor read + restore + record is atomic vs. a concurrent `apply`.
8635    /// `expected_anchor_ts` (the canary's) makes the revert conditional:
8636    /// if the latest SetDefault is no longer the one we decided on (the
8637    /// user applied a newer switch), refuse rather than undo their choice.
8638    async fn rollback_lane_inner(
8639        &self,
8640        use_case: crate::intent::UseCase,
8641        project: Option<String>,
8642        expected_anchor_seq: Option<u64>,
8643    ) -> Result<Option<String>, String> {
8644        use crate::action_ledger::{ConciergeActionEntry, ConciergeActionKind};
8645        let _guard = self.concierge_action_lock.lock().await;
8646        // One-shot "undo the last apply": anchor on the most recent
8647        // SetDefault *or* Rollback for this (lane, project). If the latest
8648        // is already a Rollback, there's nothing left to undo — refuse
8649        // rather than restore a stale value a second time.
8650        let actions = self.concierge_actions(0);
8651        let anchor = actions.iter().rev().find(|a| {
8652            matches!(
8653                a.kind,
8654                ConciergeActionKind::SetDefault | ConciergeActionKind::Rollback
8655            ) && a.use_case == Some(use_case)
8656                && a.project == project
8657        });
8658        let set = match anchor {
8659            None => return Err("no prior set-default to roll back".into()),
8660            Some(a) if a.kind == ConciergeActionKind::Rollback => {
8661                return Err("already rolled back to the prior default; nothing to undo".into())
8662            }
8663            Some(a) => a,
8664        };
8665        // Conditional revert (canary): only proceed if the anchor is still
8666        // the switch we decided on — the user may have applied a newer one.
8667        if let Some(seq) = expected_anchor_seq {
8668            if set.seq != seq {
8669                return Err("lane default changed since the canary decision; not reverting".into());
8670            }
8671        }
8672        let prior = set.prior_model_id.clone();
8673        let now = std::time::SystemTime::now()
8674            .duration_since(std::time::UNIX_EPOCH)
8675            .map(|d| d.as_secs())
8676            .unwrap_or(0);
8677
8678        match &prior {
8679            Some(m) => self.set_lane_default(project.clone(), use_case, m)?,
8680            None => {
8681                self.clear_lane_default(project.as_deref(), use_case)?;
8682            }
8683        }
8684        let detail = match &prior {
8685            Some(_) => "concierge rollback: restored prior lane default",
8686            None => "concierge rollback: cleared lane default (no prior)",
8687        };
8688        self.record_concierge_action(ConciergeActionEntry {
8689            seq: 0, // assigned by record_concierge_action
8690            kind: ConciergeActionKind::Rollback,
8691            model_id: prior.clone().unwrap_or_default(),
8692            use_case: Some(use_case),
8693            project,
8694            prior_model_id: Some(set.model_id.clone()),
8695            detail: detail.into(),
8696            timestamp: now,
8697        });
8698        Ok(prior)
8699    }
8700
8701    /// Assemble the ambient concierge status (Phase C1): per-lane usage +
8702    /// friction from the outcome ledger, the current grounded decision
8703    /// (`evaluate_concierge`), and per-model health from the profiles. A
8704    /// pull (the UI asks); proactive push stays separate.
8705    pub async fn concierge_status(
8706        &self,
8707        inference_active: bool,
8708    ) -> crate::concierge::ConciergeStatus {
8709        /// Lookback window for the usage profile: 30 days.
8710        const USAGE_WINDOW_SECS: u64 = 30 * 24 * 60 * 60;
8711
8712        let prefs = self.update_prefs();
8713        let state = crate::nudge::NudgeState::load_from(&crate::nudge::NudgeState::default_path());
8714        let hw = crate::hardware::HardwareInfo::detect();
8715        let schemas = self.list_schemas();
8716        let refs: Vec<&ModelSchema> = schemas.iter().collect();
8717        let now = std::time::SystemTime::now()
8718            .duration_since(std::time::UNIX_EPOCH)
8719            .map(|d| d.as_secs())
8720            .unwrap_or(0);
8721
8722        let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
8723        let entries = crate::outcome::read_ledger(&ledger_path, 0);
8724        let usage =
8725            crate::usage_profile::UsageProfile::from_ledger(&entries, now, USAGE_WINDOW_SECS);
8726
8727        let decision = crate::concierge::evaluate_concierge(
8728            &refs,
8729            &hw,
8730            &usage,
8731            crate::intent::QualityTier::Balanced,
8732            &prefs,
8733            &state,
8734            now,
8735            crate::concierge::DEFAULT_CONCIERGE_THROTTLE_SECS,
8736            inference_active,
8737        );
8738
8739        let tracker = self.outcome_tracker.read().await;
8740        let models = tracker
8741            .export_profiles()
8742            .iter()
8743            .map(|p| crate::concierge::ModelHealth {
8744                model_id: p.model_id.clone(),
8745                calls: p.total_calls,
8746                // Display-only: `None` when nothing resolved (not the router's
8747                // 0.5 prior), so the UI shows "no resolved signal" rather than
8748                // a misleading "50%" for a never-measured model.
8749                success_rate: p.success_rate_resolved(),
8750                avg_latency_ms: p.avg_latency_ms(),
8751                quality: p.ema_quality,
8752                excluded: tracker.is_excluded(&p.model_id),
8753            })
8754            .collect();
8755        drop(tracker);
8756
8757        // Pending verification: standing switches old enough that we'd
8758        // expect to have verified them, but lacking the resolved samples a
8759        // canary needs (low-resolution lanes). Surface them so the user can
8760        // decide rather than leaving them silently unverifiable.
8761        const STALE_VERIFY_SECS: u64 = 14 * 24 * 60 * 60;
8762        let actions = self.concierge_actions(0);
8763        let mut latest: std::collections::BTreeMap<
8764            crate::intent::UseCase,
8765            &crate::action_ledger::ConciergeActionEntry,
8766        > = std::collections::BTreeMap::new();
8767        for a in &actions {
8768            if a.project.is_none() {
8769                if let Some(uc) = a.use_case {
8770                    latest.insert(uc, a);
8771                }
8772            }
8773        }
8774        let mut pending_verification = Vec::new();
8775        for (uc, a) in latest {
8776            if a.kind != crate::action_ledger::ConciergeActionKind::SetDefault {
8777                continue;
8778            }
8779            if now.saturating_sub(a.timestamp) < STALE_VERIFY_SECS {
8780                continue; // still within the verification window
8781            }
8782            let resolved = entries
8783                .iter()
8784                .filter(|e| {
8785                    e.timestamp >= a.timestamp
8786                        && e.model_id == a.model_id
8787                        && crate::usage_profile::use_case_for_task(e.task) == uc
8788                        && e.success.is_some()
8789                })
8790                .count() as u64;
8791            if resolved < crate::concierge::CANARY_MIN_SAMPLES {
8792                pending_verification.push(crate::concierge::PendingVerification {
8793                    use_case: uc,
8794                    model_id: a.model_id.clone(),
8795                    set_at: a.timestamp,
8796                    resolved_samples: resolved,
8797                    needed: crate::concierge::CANARY_MIN_SAMPLES,
8798                });
8799            }
8800        }
8801
8802        crate::concierge::ConciergeStatus {
8803            lanes: usage.active_lanes().into_iter().cloned().collect(),
8804            decision,
8805            models,
8806            pending_verification,
8807        }
8808    }
8809
8810    /// Detect upgrades combining curated rules with upstream Hub discovery,
8811    /// honoring update preferences (channel/policy) and the TTL cache. Upstream
8812    /// probing only happens on the `Latest` channel and is offline-safe.
8813    pub async fn detect_upgrades(&self) -> Vec<crate::upgrade::UpgradeFinding> {
8814        let prefs = self.update_prefs();
8815        let curated = self.unified_registry.available_upgrades();
8816        let schemas = self.list_schemas();
8817        let refs: Vec<&ModelSchema> = schemas.iter().collect();
8818        let probe = crate::upgrade::HuggingFaceProbe::new();
8819        let now = std::time::SystemTime::now()
8820            .duration_since(std::time::UNIX_EPOCH)
8821            .map(|d| d.as_secs())
8822            .unwrap_or(0);
8823        crate::upgrade::detect_upgrades(
8824            curated,
8825            &refs,
8826            &prefs,
8827            &probe,
8828            &crate::upgrade::UpgradeCache::default_path(),
8829            now,
8830            crate::upgrade::DEFAULT_TTL_SECS,
8831        )
8832        .await
8833    }
8834
8835    /// List all known models and their download status (legacy).
8836    /// List all model schemas from the unified registry (full metadata).
8837    pub fn list_schemas(&self) -> Vec<ModelSchema> {
8838        self.catalog_registry_snapshot()
8839            .list()
8840            .into_iter()
8841            .cloned()
8842            .collect()
8843    }
8844
8845    /// Deterministic immutable catalog view used to bind inference routing to
8846    /// exact model rows. Runtime availability never participates in either
8847    /// row digests or the catalog revision.
8848    pub fn catalog_snapshot(&self) -> Result<CatalogSnapshot, String> {
8849        CatalogSnapshot::new(self.unified_registry.list().into_iter().cloned())
8850    }
8851
8852    /// Return one registered schema without refreshing availability.
8853    ///
8854    /// This is for identity/provenance checks that must reflect signed catalog
8855    /// overrides while remaining local and side-effect free.
8856    pub fn registered_schema(&self, id: &str) -> Option<ModelSchema> {
8857        self.unified_registry.registered_schema(id).cloned()
8858    }
8859
8860    pub fn list_models(&self) -> Vec<models::ModelInfo> {
8861        self.registry.list_models()
8862    }
8863
8864    /// Whether a caller-supplied model name resolves to a registered schema.
8865    ///
8866    /// Answers the question generation asks, by the same two routes and in the
8867    /// same order: exact id, then the case-insensitive name lookup. It is
8868    /// deliberately NOT `list_models()`, which returns the on-device catalog —
8869    /// checking a remote model id against that set reports every cloud model as
8870    /// unknown.
8871    ///
8872    /// Exists so a caller that fans out to several named models can refuse a
8873    /// typo up front instead of discovering it as a generation error per
8874    /// request. Says nothing about whether the model is currently *reachable*
8875    /// (credentials, network) — only that the name is one CAR knows.
8876    pub fn knows_model(&self, name: &str) -> bool {
8877        self.model_schema(name).is_some()
8878    }
8879
8880    /// The registered schema behind a model name or id, resolved exactly as
8881    /// [`Self::knows_model`] resolves it — exact id first, then the
8882    /// case-insensitive name lookup.
8883    ///
8884    /// Defined together with `knows_model` so the two cannot drift into
8885    /// disagreeing about which names exist, and it resolves in the SAME order
8886    /// generation does (`get(id).or_else(find_by_name(id))`, as at the routing
8887    /// sites) — so a caller asking "will these two names reach the same model?"
8888    /// gets the answer that will actually hold at generation time, including
8889    /// `find_by_name`'s MLX-variant redirect on Apple silicon.
8890    ///
8891    /// That fidelity is the point, and it is NOT a canonical identity oracle.
8892    /// The lookup is over a `HashMap`, so if two rows share a display name the
8893    /// one returned is arbitrary — stable within a process, not across
8894    /// restarts. Generation has the same property, so a caller comparing what
8895    /// will run stays correct; a caller needing a stable identity for storage
8896    /// wants the exact id via `registered_schema`.
8897    pub fn model_schema(&self, name: &str) -> Option<&ModelSchema> {
8898        self.unified_registry
8899            .get(name)
8900            .or_else(|| self.unified_registry.find_by_name(name))
8901    }
8902
8903    /// The name of a downloaded, generation-capable on-device model to use as a
8904    /// last-resort fallback so a remote-only chain that fails (an expired cloud
8905    /// credential, an offline network) can still answer locally instead of
8906    /// erroring with nothing left. Prefers the smallest installed model (fastest
8907    /// to load) and excludes dedicated embedding models.
8908    ///
8909    /// When `needs_tools` is set, ONLY an installed model that actually parses
8910    /// tool calls (the `ToolUse` capability) qualifies — a text-only local model
8911    /// would be dropped by the tool-capability guard in the generate loop and
8912    /// help nothing, so returning it as a "fallback" just wastes an attempt.
8913    /// Returns `None` when no installed model can serve the turn (the chain then
8914    /// surfaces the actionable remote error, e.g. the re-authenticate hint).
8915    fn first_installed_local_model(&self, needs_tools: bool) -> Option<String> {
8916        // Enumerate through the UNIFIED registry, not the legacy GGUF-only
8917        // `list_models()`. The legacy catalog keys `downloaded` off a
8918        // `{name}/model.gguf` file, so it is blind to MLX installs (stored as
8919        // config.json + safetensors, no `.gguf`) — i.e. every model on Apple
8920        // Silicon, the platform where degrading to on-device matters most. The
8921        // unified registry's `ready_without_download` understands both the GGUF
8922        // and MLX layouts, so this fallback fires on macOS too.
8923        let mut candidates: Vec<_> = self
8924            .unified_registry
8925            .all()
8926            // In-process on-device backends ONLY (GGUF via candle, or in-process
8927            // MLX) — the same set `ensure_local_backend` drives. `is_local()`
8928            // also matches `VllmMlx`, whose `ready_without_download` is
8929            // unconditionally true but which needs an external vLLM-MLX server
8930            // that is usually not running (and never on Windows/Linux); picking
8931            // one would be a dead fallback, not an on-device answer.
8932            .filter(|s| s.is_local() && !s.is_vllm_mlx())
8933            // Apple's FoundationModels reports `ready_without_download == true`
8934            // on every platform (there is nothing to download), but it only
8935            // EXECUTES on Apple Silicon — off-Apple its `available` is false, a
8936            // platform-static fact, so the registry's boot-time value is reliable
8937            // here even though the frozen registry is otherwise untrustworthy for
8938            // availability (car#651). Without this, a remote-only fallback chain
8939            // on Windows/Linux appends `apple-foundation` as a "local last
8940            // resort", it fails with `model not found: apple-foundation`, and that
8941            // error MASKS the real remote failure (a Windows CRLF-broken fixture
8942            // surfaced exactly this). Scoped to this source on purpose: other
8943            // local models' availability CAN change at runtime (a GGUF pulled
8944            // after boot), which is why the readiness gate below stays
8945            // `ready_without_download`, not `available`. `is_foundation_models`'s
8946            // own docs say callers must verify runtime availability before
8947            // dispatch — this is that check.
8948            .filter(|s| !s.is_foundation_models() || s.available)
8949            .filter(|s| s.has_capability(ModelCapability::Generate))
8950            // A tools-bearing turn needs a model that actually parses tool calls
8951            // (ToolUse); a text-only local model would be dropped by the
8952            // generate loop's capability guard and waste an attempt.
8953            .filter(|s| !needs_tools || s.has_capability(ModelCapability::ToolUse))
8954            .filter(|s| self.model_management.car_enabled(&s.id).unwrap_or(false))
8955            .filter(|s| self.unified_registry.ready_without_download(&s.id) == Some(true))
8956            .collect();
8957        // Smallest first — fastest to load for a last-resort answer.
8958        candidates.sort_by_key(|s| s.size_mb());
8959
8960        // Don't hand back a model this machine can't actually run RIGHT NOW.
8961        // The last-resort fallback fires when a remote call fails, and that
8962        // often coincides with a loaded machine; picking a local model without
8963        // the free RAM to run it turns a recoverable remote error into a hard
8964        // Metal OOM abort mid-generation (observed: a transient Parslee outage
8965        // during a browser-automation run fell back to on-device and crashed
8966        // with a 48 GB `[metal::malloc]` allocation on a box with ~2 GB free).
8967        // The routing-time `fits_now` guard covers model SELECTION; this covers
8968        // the fallback-APPEND path, which bypasses it. Weights plus a working
8969        // reserve for the KV cache and activations must fit in available RAM.
8970        // If availability can't be read, keep prior behavior (append anyway).
8971        if let Some(avail) = crate::hardware::available_ram_mb() {
8972            const WORKING_RESERVE_MB: u64 = 2048;
8973            candidates.retain(|s| s.size_mb().saturating_add(WORKING_RESERVE_MB) <= avail);
8974        }
8975
8976        candidates.first().map(|s| s.name.clone())
8977    }
8978
8979    /// Download a model if not already present.
8980    pub async fn pull_model(&self, name: &str) -> Result<std::path::PathBuf, InferenceError> {
8981        self.pull_model_with_progress(name, &crate::download::ProgressSink::none())
8982            .await
8983    }
8984
8985    /// Download a model if not already present, reporting progress to `sink`
8986    /// and enforcing the acquisition lifecycle (per-model lock, disk preflight,
8987    /// lifecycle events). The CLI and daemon use this to show live progress.
8988    pub async fn pull_model_with_progress(
8989        &self,
8990        name: &str,
8991        sink: &crate::download::ProgressSink,
8992    ) -> Result<std::path::PathBuf, InferenceError> {
8993        let schema = self
8994            .unified_registry
8995            .find_by_name(name)
8996            .or_else(|| self.unified_registry.get(name))
8997            .ok_or_else(|| InferenceError::ModelNotFound(name.to_string()))?;
8998        let _mutation = self.model_management.begin_mutation(&schema.id)?;
8999        if let Some(receipt) = self.model_management.load_receipt(&schema.id)? {
9000            // `can_remove=false` can mean a valid receipt-backed directory
9001            // whose recursive cleanup is deliberately unsupported. Removal
9002            // capability must never gate reuse/re-enable of valid weights.
9003            let _ = self.model_management.can_remove(&schema.id)?;
9004            self.model_management.clear_tombstone(&schema.id)?;
9005            return Ok(receipt.managed_path);
9006        }
9007        if let Some(receipt) = self.model_management.resume_install_intent(&schema.id)? {
9008            return Ok(receipt.managed_path);
9009        }
9010        let expected_managed = self.model_management.models_dir().join(&schema.name);
9011        if std::fs::symlink_metadata(&expected_managed).is_ok() {
9012            return Err(InferenceError::InferenceFailed(format!(
9013                "model {} has a pre-existing unreceipted artifact at {}; use models.adopt or move it before pulling",
9014                schema.id,
9015                expected_managed.display()
9016            )));
9017        }
9018        let staging = self.model_management.create_install_staging(&schema.id)?;
9019        let installed = match self
9020            .unified_registry
9021            .ensure_local_with_progress_staged(&schema.id, sink, &staging)
9022            .await
9023        {
9024            Ok(installed) => installed,
9025            Err(error) => {
9026                self.model_management.discard_install_staging(&staging);
9027                return Err(error);
9028            }
9029        };
9030        let installed_is_staging = match (installed.canonicalize(), staging.canonicalize()) {
9031            (Ok(installed), Ok(staging)) => installed == staging,
9032            _ => false,
9033        };
9034        let generation = self
9035            .resource_policy_generation
9036            .load(std::sync::atomic::Ordering::Acquire);
9037        let managed_path = self.model_management.models_dir().join(&schema.name);
9038        let managed = if installed_is_staging {
9039            let receipt = self.model_management.install_receipt_for_publication(
9040                &schema.id,
9041                model_source_identity(schema),
9042                generation,
9043                false,
9044                managed_path,
9045                model_management::ManagedArtifactKind::Directory,
9046                &staging,
9047            )?;
9048            self.model_management
9049                .begin_install_intent(receipt, Some(&staging))?;
9050            self.model_management
9051                .publish_install_staging(&schema.id, &staging, &schema.name)?
9052        } else {
9053            self.model_management.discard_install_staging(&staging);
9054            let receipt = self.model_management.install_receipt_for_publication(
9055                &schema.id,
9056                model_source_identity(schema),
9057                generation,
9058                false,
9059                managed_path,
9060                model_management::ManagedArtifactKind::Symlink,
9061                &installed,
9062            )?;
9063            self.model_management.begin_install_intent(receipt, None)?;
9064            self.model_management
9065                .materialize_managed_projection(&schema.name, &installed)?
9066        };
9067        let receipt = self
9068            .model_management
9069            .resume_install_intent(&schema.id)?
9070            .ok_or_else(|| {
9071                InferenceError::InferenceFailed(format!(
9072                    "model {} publication completed without a durable install intent",
9073                    schema.id
9074                ))
9075            })?;
9076        debug_assert_eq!(receipt.managed_path, managed);
9077        Ok(receipt.managed_path)
9078    }
9079
9080    /// Explicitly adopt an already-usable local artifact into CAR ownership.
9081    /// The source path is resolved from the registry; callers cannot nominate
9082    /// an arbitrary deletion target.
9083    pub async fn adopt_model_into_car(
9084        &self,
9085        model_id: &str,
9086    ) -> Result<model_management::InstallReceipt, InferenceError> {
9087        let schema = self
9088            .unified_registry
9089            .get(model_id)
9090            .or_else(|| self.unified_registry.find_by_name(model_id))
9091            .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
9092        if !schema.is_local() || !schema.downloads_weights() {
9093            return Err(InferenceError::InferenceFailed(format!(
9094                "model {} is not a CAR-manageable local artifact",
9095                schema.id
9096            )));
9097        }
9098        let existing = self
9099            .unified_registry
9100            .existing_local_artifact(&schema.id)
9101            .ok_or_else(|| {
9102                InferenceError::InferenceFailed(format!(
9103                    "model {} has no usable local artifact to adopt",
9104                    schema.id
9105                ))
9106            })?;
9107        let _mutation = self.model_management.begin_mutation(&schema.id)?;
9108        let existing_is_managed_symlink = std::fs::symlink_metadata(&existing)
9109            .is_ok_and(|metadata| metadata.file_type().is_symlink())
9110            && existing.parent().is_some_and(|parent| {
9111                parent.canonicalize().ok() == self.model_management.models_dir().canonicalize().ok()
9112            });
9113        if let Some(receipt) = self.model_management.resume_install_intent(&schema.id)? {
9114            return Ok(receipt);
9115        }
9116        let generation = self
9117            .resource_policy_generation
9118            .load(std::sync::atomic::Ordering::Acquire);
9119        let managed = if existing_is_managed_symlink {
9120            existing
9121        } else {
9122            let managed = self.model_management.adopted_projection_path(&schema.id)?;
9123            let receipt = self.model_management.install_receipt_for_publication(
9124                &schema.id,
9125                model_source_identity(schema),
9126                generation,
9127                true,
9128                managed,
9129                model_management::ManagedArtifactKind::Symlink,
9130                &existing,
9131            )?;
9132            self.model_management.begin_install_intent(receipt, None)?;
9133            self.model_management
9134                .materialize_adopted_projection(&schema.id, &existing)?
9135        };
9136        if existing_is_managed_symlink {
9137            self.model_management
9138                .record_managed_artifact(
9139                    &schema.id,
9140                    model_source_identity(schema),
9141                    None,
9142                    generation,
9143                    true,
9144                    managed,
9145                )
9146                .map_err(InferenceError::from)
9147        } else {
9148            self.model_management
9149                .resume_install_intent(&schema.id)?
9150                .ok_or_else(|| {
9151                    InferenceError::InferenceFailed(format!(
9152                        "model {} adoption completed without a durable install intent",
9153                        schema.id
9154                    ))
9155                })
9156        }
9157    }
9158
9159    /// Safely remove only CAR-owned linkage after every Task 3 runtime owner
9160    /// acknowledges release. Shared Hugging Face blobs remain untouched.
9161    pub async fn remove_model_from_car(
9162        &self,
9163        model_id: &str,
9164    ) -> Result<model_management::RemoveFromCarResult, InferenceError> {
9165        let schema = self
9166            .unified_registry
9167            .get(model_id)
9168            .or_else(|| self.unified_registry.find_by_name(model_id))
9169            .ok_or_else(|| InferenceError::ModelNotFound(model_id.to_string()))?;
9170        if !schema.is_local() || !schema.downloads_weights() {
9171            return Err(InferenceError::InferenceFailed(format!(
9172                "model {} is not a CAR-owned local artifact",
9173                schema.id
9174            )));
9175        }
9176        if self.model_management.load_receipt(&schema.id)?.is_none() {
9177            if self.model_management.car_enabled(&schema.id)? {
9178                return Err(model_management::ModelManagementError::MissingReceipt {
9179                    model_id: schema.id.clone(),
9180                }
9181                .into());
9182            }
9183            // A prior request may have completed removal and lost its response.
9184            // Resume the durable tombstone result without re-running runtime
9185            // maintenance against an artifact that no longer exists.
9186            let generation = self
9187                .resource_policy_generation
9188                .load(std::sync::atomic::Ordering::Acquire)
9189                .saturating_add(1);
9190            return self
9191                .model_management
9192                .begin_mutation(&schema.id)?
9193                .remove(generation)
9194                .map_err(InferenceError::from);
9195        }
9196        let mutation = self.model_management.begin_mutation(&schema.id)?;
9197        let _maintenance = self
9198            .prepare_local_model_removal(&schema.id)
9199            .await
9200            .map_err(|error| InferenceError::InferenceFailed(error.to_string()))?;
9201        let generation = self
9202            .resource_policy_generation
9203            .load(std::sync::atomic::Ordering::Acquire)
9204            .saturating_add(1);
9205        mutation.remove(generation).map_err(InferenceError::from)
9206    }
9207
9208    /// Current update preferences. A team-shared project `.car/update-prefs.json`
9209    /// (found by walking up from cwd) overrides the user `~/.car/update-prefs.json`;
9210    /// defaults if neither exists. Loaded on demand — read at onboarding/
9211    /// upgrade-check frequency, not on the inference hot path.
9212    pub fn update_prefs(&self) -> crate::update_prefs::UpdatePreferences {
9213        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
9214        crate::update_prefs::UpdatePreferences::load_effective(&cwd).unwrap_or_default()
9215    }
9216
9217    /// Persist update preferences to `~/.car/update-prefs.json`.
9218    pub fn set_update_prefs(
9219        &self,
9220        prefs: &crate::update_prefs::UpdatePreferences,
9221    ) -> Result<(), InferenceError> {
9222        prefs.save().map_err(InferenceError::InferenceFailed)
9223    }
9224
9225    /// Legacy synchronous removal is intentionally disabled because it cannot
9226    /// coordinate active workers, cross-process leases, or receipt ownership.
9227    /// Use [`Self::remove_model_from_car`] instead.
9228    #[deprecated(note = "use async remove_model_from_car for receipt-backed safe removal")]
9229    pub fn remove_model(&self, name: &str) -> Result<(), InferenceError> {
9230        Err(InferenceError::InferenceFailed(format!(
9231            "legacy removal for {name} is disabled; use async receipt-backed remove_model_from_car"
9232        )))
9233    }
9234
9235    /// Register a model at the public runtime boundary.
9236    ///
9237    /// The registry normalizes every such schema to Community trust. Project
9238    /// curation is reserved for compiled builtins and signature-verified
9239    /// catalogs inside this crate.
9240    pub fn register_model(&mut self, schema: ModelSchema) {
9241        self.unified_registry.register(schema);
9242    }
9243
9244    /// Register a model from a user-controlled schema boundary.
9245    pub fn register_user_model(&mut self, schema: ModelSchema) {
9246        self.unified_registry.register_user_model(schema);
9247    }
9248
9249    /// Discover generic MLX models from a running vLLM-MLX server and register them.
9250    /// Returns the number of discovered models added or refreshed in the registry.
9251    pub async fn discover_vllm_mlx_models(&mut self) -> usize {
9252        let config = vllm_mlx::VllmMlxConfig::default();
9253        if !config.auto_discover {
9254            return 0;
9255        }
9256        vllm_mlx::discover_and_register(&config, &mut self.unified_registry).await
9257    }
9258
9259    /// Get outcome tracker for external use (e.g., memgine integration).
9260    pub fn outcome_tracker(&self) -> Arc<RwLock<OutcomeTracker>> {
9261        self.outcome_tracker.clone()
9262    }
9263
9264    /// Auto-save outcomes and key pool stats silently (called after every
9265    /// inference call). Debounced: the outcome profiles are only written
9266    /// when the tracker is dirty AND at least `OUTCOME_FLUSH_INTERVAL` has
9267    /// passed since the last flush — so a busy machine doesn't serialize
9268    /// and rewrite the whole profiles file on every single call. A forced,
9269    /// unconditional flush is available via [`save_outcomes`] (used on
9270    /// shutdown / by the dream task).
9271    async fn auto_save_outcomes(&self) {
9272        const OUTCOME_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
9273
9274        // An on-device inference worker (car-releases#74) is a stateless compute
9275        // slave: the parent daemon owns outcome bookkeeping and persistence for
9276        // every offloaded call (it wraps the offload in its own record_start /
9277        // record_complete). If the worker also wrote `outcome_profiles.json` /
9278        // `outcome_ledger.jsonl` in the shared models_dir it would race the
9279        // daemon's atomic writes and clobber the router's learning with a
9280        // local-only view. So the worker never persists.
9281        if crate::offload::is_offload_worker() {
9282            return;
9283        }
9284
9285        // Read the debounce gate without holding the lock across an await.
9286        let due = {
9287            let last = self.last_outcome_flush.lock().unwrap();
9288            last.is_none_or(|t| t.elapsed() >= OUTCOME_FLUSH_INTERVAL)
9289        };
9290        if due {
9291            match self.persist_outcomes().await {
9292                Ok(did) => {
9293                    if did {
9294                        *self.last_outcome_flush.lock().unwrap() = Some(Instant::now());
9295                    }
9296                }
9297                Err(e) => tracing::debug!("auto-save outcomes failed: {}", e),
9298            }
9299        }
9300
9301        if let Err(e) = self.save_key_pool_stats().await {
9302            tracing::debug!("auto-save key pool stats failed: {}", e);
9303        }
9304    }
9305
9306    /// Persist both outcome artifacts: append the resolved-outcome ledger
9307    /// (the durable, attributable receipts — append-only JSONL) and save
9308    /// the derived aggregate profiles (dirty-gated, atomic). Returns
9309    /// whether anything was written. Shared by the debounced per-call path
9310    /// and the immediate [`flush_outcomes`] backstop.
9311    async fn persist_outcomes(&self) -> Result<bool, std::io::Error> {
9312        const PENDING_TTL_SECS: u64 = 300;
9313        // Under one write lock: evict stale pending (bounds memory +
9314        // de-biases the ledger via Inconclusive receipts) and drain the
9315        // resulting receipts.
9316        let entries = {
9317            let mut tracker = self.outcome_tracker.write().await;
9318            tracker.sweep_pending(PENDING_TTL_SECS);
9319            tracker.drain_ledger()
9320        };
9321        let mut did = false;
9322        // Privacy opt-out: CAR_NO_OUTCOME_LEDGER drops per-call receipts
9323        // entirely (the buffer is still drained so it can't grow). Aggregate
9324        // profiles still persist — routing needs them — but no attributable
9325        // per-call record is written.
9326        let ledger_disabled = std::env::var_os("CAR_NO_OUTCOME_LEDGER").is_some();
9327        if !entries.is_empty() && !ledger_disabled {
9328            let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9329            let _guard = self.ledger_io_lock.lock().await;
9330            crate::outcome::append_ledger_entries(&ledger_path, &entries)?;
9331            did = true;
9332        }
9333        // Profiles: dirty-gated atomic save.
9334        let profiles_path = self.config.state_models_dir().join("outcome_profiles.json");
9335        let wrote = {
9336            let mut tracker = self.outcome_tracker.write().await;
9337            tracker.save_if_dirty(&profiles_path)?
9338        };
9339        Ok(did || wrote)
9340    }
9341
9342    /// Persist outcome profiles to disk for cross-session learning (#13).
9343    /// Unconditional (force) save — writes even if nothing changed.
9344    /// Prefer [`flush_outcomes`](crate::InferenceEngine::flush_outcomes) for shutdown / periodic flushes; the
9345    /// per-call path uses [`auto_save_outcomes`](crate::InferenceEngine::auto_save_outcomes), which debounces.
9346    pub async fn save_outcomes(&self) -> Result<(), std::io::Error> {
9347        let tracker = self.outcome_tracker.read().await;
9348        let path = self.config.state_models_dir().join("outcome_profiles.json");
9349        tracker.save_to_file(&path)
9350    }
9351
9352    /// Flush outcome profiles to disk **iff** dirty, ignoring the per-call
9353    /// time debounce. Returns whether a write happened. This is the
9354    /// durable-receipt backstop: the daemon calls it on a periodic timer
9355    /// and on graceful shutdown so the last (sub-`OUTCOME_FLUSH_INTERVAL`)
9356    /// window of learning is never lost. Cheap when clean (no write).
9357    pub async fn flush_outcomes(&self) -> Result<bool, std::io::Error> {
9358        let did = self.persist_outcomes().await?;
9359        if did {
9360            *self.last_outcome_flush.lock().unwrap() = Some(Instant::now());
9361        }
9362        Ok(did)
9363    }
9364
9365    /// Enforce the outcome-ledger retention bound (privacy + disk). A cheap
9366    /// no-op when under the cap; the daemon calls it periodically.
9367    pub async fn prune_outcome_ledger(&self, max_entries: usize) -> std::io::Result<()> {
9368        let path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9369        let _guard = self.ledger_io_lock.lock().await;
9370        crate::outcome::prune_ledger(&path, max_entries)
9371    }
9372
9373    /// Persist key pool stats to disk.
9374    pub async fn save_key_pool_stats(&self) -> Result<(), std::io::Error> {
9375        let path = self.config.state_models_dir().join("key_pool_stats.json");
9376        self.remote_backend.key_pool.save_stats(&path).await
9377    }
9378
9379    /// Get key pool stats for all endpoints.
9380    pub async fn key_pool_stats(
9381        &self,
9382    ) -> std::collections::HashMap<String, Vec<key_pool::KeyStats>> {
9383        self.remote_backend.key_pool.all_stats().await
9384    }
9385
9386    /// Export model performance profiles for persistence.
9387    pub async fn export_profiles(&self) -> Vec<ModelProfile> {
9388        let tracker = self.outcome_tracker.read().await;
9389        tracker.export_profiles()
9390    }
9391
9392    /// Fold the durable outcome ledger into the deployment scoreboard — the
9393    /// per-model, priced, OUTCOME-DENOMINATED view (cost-per-success,
9394    /// tokens-per-success, success-rate). Reads the same `outcome_ledger.jsonl`
9395    /// the tracker flushes to (cross-session, survives restart) and joins
9396    /// per-model catalog prices from the registry so `usd_per_success` is the
9397    /// honest "cry once" figure. Unpriced models keep a `None` dollar figure
9398    /// rather than a fabricated one. See [`crate::scoreboard::Scoreboard`].
9399    pub fn outcome_scoreboard(&self) -> crate::scoreboard::Scoreboard {
9400        let ledger_path = self.config.state_models_dir().join("outcome_ledger.jsonl");
9401        let entries = crate::outcome::read_ledger(&ledger_path, 0);
9402        // #369: shadow-calibration telemetry folds the SAME durable ledger —
9403        // surface how the router's quality constants would tune as graded
9404        // evidence accumulates, without touching the live constants or routing.
9405        crate::calibration::ShadowCalibration::from_ledger(&entries).emit();
9406        crate::scoreboard::Scoreboard::from_ledger(&entries, |id| {
9407            let s = self
9408                .unified_registry
9409                .get(id)
9410                .or_else(|| self.unified_registry.find_by_name(id))?;
9411            match (s.cost.input_per_mtok, s.cost.output_per_mtok) {
9412                // Cache economics come from the model's protocol so cached
9413                // tokens are priced at the right per-provider discount
9414                // (Anthropic 0.1×/1.25×, OpenAI 0.5×/no-write).
9415                (Some(input_per_mtok), Some(output_per_mtok)) => {
9416                    Some(crate::scoreboard::PriceModel {
9417                        input_per_mtok,
9418                        output_per_mtok,
9419                        cache: s.cache_rates(),
9420                        is_estimate: !s.cost.pricing_tiers.is_empty(),
9421                    })
9422                }
9423                _ => None,
9424            }
9425        })
9426    }
9427
9428    /// Import model performance profiles (from persistence).
9429    pub async fn import_profiles(&self, profiles: Vec<ModelProfile>) {
9430        let mut tracker = self.outcome_tracker.write().await;
9431        tracker.import_profiles(profiles);
9432    }
9433
9434    /// Ensure the managed local speech runtime exists and return its root
9435    /// directory — the same root [`speech_health`](Self::speech_health)
9436    /// reports, on every platform.
9437    ///
9438    /// Apple Silicon used to short-circuit here: native MLX backends were taken
9439    /// to replace the Python runtime outright, so this only created
9440    /// `models_dir` and handed *that* back without ever provisioning the
9441    /// managed runtime. Since #640 the runtime is a live fallback there too
9442    /// (the native backends can't load every catalogued checkpoint) and
9443    /// `speech doctor` reports its real state — so a "successful" install
9444    /// contradicted doctor, printed a path doctor never mentions, and pushed
9445    /// the multi-minute venv+pip bootstrap onto the first synthesis
9446    /// (Parslee-ai/car#649). Provision it up front on every platform instead.
9447    ///
9448    /// The one asymmetry that remains is what a bootstrap *failure* means.
9449    /// Off Apple Silicon the managed runtime is the only local speech path, so
9450    /// failing to build it fails the call. On Apple Silicon it sits behind
9451    /// working native backends, so a missing `uv` degrades rather than breaks:
9452    /// the root comes back either way, and callers should report
9453    /// `speech_health().runtime.installed` rather than read a returned path as
9454    /// proof of success. Either way the returned directory exists — a method
9455    /// called "prepare" leaves the thing prepared (Parslee-ai/car#626).
9456    pub async fn prepare_speech_runtime(&self) -> Result<PathBuf, InferenceError> {
9457        match self.ensure_speech_runtime().await {
9458            Ok(runtime) => Ok(runtime.root),
9459            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
9460            Err(err) => {
9461                let root = speech_runtime_root_from_models_dir(&self.config.models_dir);
9462                tracing::warn!(
9463                    error = %err,
9464                    root = %root.display(),
9465                    "managed speech runtime could not be provisioned; native MLX \
9466                     backends still cover the default local models, but catalogued \
9467                     checkpoints they cannot load will be unavailable"
9468                );
9469                std::fs::create_dir_all(&root)?;
9470                Ok(root)
9471            }
9472            #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
9473            Err(err) => Err(err),
9474        }
9475    }
9476
9477    /// Override speech routing preferences for the current engine instance.
9478    pub fn set_speech_policy(&mut self, policy: SpeechPolicy) {
9479        self.speech_policy = policy;
9480    }
9481
9482    pub fn set_routing_config(&mut self, config: RoutingConfig) {
9483        self.adaptive_router.set_config(config);
9484    }
9485
9486    /// Download the curated local speech model set into the shared Hugging Face cache.
9487    pub async fn install_curated_speech(
9488        &mut self,
9489    ) -> Result<Vec<SpeechInstallReport>, InferenceError> {
9490        // Provisioning the managed runtime is best-effort here, deliberately.
9491        // It is an Apple-only Python stack (`uv venv` + `pip install
9492        // mlx-audio`, and `mlx` publishes no Windows/Linux wheels), and off
9493        // Apple Silicon `prepare_speech_runtime` returns `Err` whenever it
9494        // cannot be built. Propagating that aborted the whole command before
9495        // the whisper.cpp block below — the one local speech model that *does*
9496        // run on Windows and Linux, and the one `car speech doctor` tells
9497        // users to run this command for (car#678). A runtime that could not be
9498        // provisioned is reported by `speech_health().runtime.installed`,
9499        // which the CLI prints; it is not a reason to skip the models.
9500        // `speech.prepare` still fails loudly, since provisioning the runtime
9501        // is that call's entire job.
9502        if let Err(error) = self.prepare_speech_runtime().await {
9503            tracing::warn!(
9504                %error,
9505                "managed speech runtime could not be provisioned; installing the \
9506                 models that do not depend on it"
9507            );
9508        }
9509        let schemas = self.list_schemas();
9510        let mut repos = Vec::new();
9511        for schema in &schemas {
9512            if !schema.is_mlx() || !schema.tags.iter().any(|tag| tag == "speech") {
9513                continue;
9514            }
9515            // MLX runs on Apple Silicon only — the registry marks every MLX
9516            // schema unavailable elsewhere. Without this the command would
9517            // pull well over a gigabyte of Kokoro / Parakeet / Qwen3-TTS
9518            // weights onto a Windows or Linux box that can never load them,
9519            // which only became reachable once the abort above was removed.
9520            if !schema.available {
9521                continue;
9522            }
9523            if let ModelSource::Mlx { hf_repo, .. } = &schema.source {
9524                if !repos.iter().any(|existing: &String| existing == hf_repo) {
9525                    repos.push(hf_repo.clone());
9526                }
9527            }
9528        }
9529
9530        let mut installed = Vec::new();
9531        for repo in repos {
9532            let (snapshot_path, files_downloaded) = download_hf_repo_snapshot(&repo).await?;
9533            let name = schemas
9534                .iter()
9535                .find(|schema| {
9536                    matches!(&schema.source, ModelSource::Mlx { hf_repo, .. } if hf_repo == &repo)
9537                })
9538                .map(|schema| schema.name.clone())
9539                .unwrap_or_else(|| repo.clone());
9540            installed.push(SpeechInstallReport {
9541                name,
9542                hf_repo: repo,
9543                snapshot_path,
9544                files_downloaded,
9545            });
9546        }
9547
9548        // whisper.cpp catalog entries (cross-platform local STT) fetch their
9549        // ggml model from ggerganov/whisper.cpp into ~/.tokhn/whisper/ — a
9550        // different store than the MLX HF snapshots above, so install it here.
9551        for schema in &schemas {
9552            if !schema.tags.iter().any(|tag| tag == "speech") {
9553                continue;
9554            }
9555            if let ModelSource::WhisperCpp { model } = &schema.source {
9556                let model = model.clone();
9557                let name = schema.name.clone();
9558                let path = tokio::task::spawn_blocking(move || car_whisper::ensure_model(&model))
9559                    .await
9560                    .map_err(|e| InferenceError::InferenceFailed(format!("whisper join: {e}")))?
9561                    .map_err(|e| {
9562                        InferenceError::InferenceFailed(format!("whisper model fetch: {e}"))
9563                    })?;
9564                installed.push(SpeechInstallReport {
9565                    name,
9566                    hf_repo: "ggerganov/whisper.cpp".to_string(),
9567                    snapshot_path: path,
9568                    files_downloaded: 1,
9569                });
9570            }
9571        }
9572
9573        self.unified_registry.refresh_availability();
9574        Ok(installed)
9575    }
9576
9577    /// Report speech runtime, model cache, and remote-provider health.
9578    pub fn speech_health(&self) -> SpeechHealthReport {
9579        let local_stt_default =
9580            self.speech_health_default_name(ModelCapability::SpeechToText, true, false);
9581        let local_tts_default =
9582            self.speech_health_default_name(ModelCapability::TextToSpeech, true, false);
9583        let remote_stt_default =
9584            self.speech_health_default_name(ModelCapability::SpeechToText, false, true);
9585        let remote_tts_default =
9586            self.speech_health_default_name(ModelCapability::TextToSpeech, false, true);
9587
9588        let mut local_models = Vec::new();
9589        let mut remote_models = Vec::new();
9590        for schema in self.list_schemas() {
9591            let capability = if schema.has_capability(ModelCapability::SpeechToText) {
9592                Some(ModelCapability::SpeechToText)
9593            } else if schema.has_capability(ModelCapability::TextToSpeech) {
9594                Some(ModelCapability::TextToSpeech)
9595            } else {
9596                None
9597            };
9598            let Some(capability) = capability else {
9599                continue;
9600            };
9601
9602            let selected_by_default = local_stt_default
9603                .as_ref()
9604                .is_some_and(|name| name == &schema.name)
9605                || local_tts_default
9606                    .as_ref()
9607                    .is_some_and(|name| name == &schema.name)
9608                || remote_stt_default
9609                    .as_ref()
9610                    .is_some_and(|name| name == &schema.name)
9611                || remote_tts_default
9612                    .as_ref()
9613                    .is_some_and(|name| name == &schema.name);
9614
9615            let health = SpeechModelHealth {
9616                id: schema.id.clone(),
9617                name: schema.name.clone(),
9618                provider: schema.provider.clone(),
9619                capability,
9620                is_local: schema.is_local(),
9621                available: schema.available,
9622                cached: speech_model_cached(&schema),
9623                selected_by_default,
9624                source: speech_model_source_label(&schema),
9625            };
9626            if schema.is_local() {
9627                local_models.push(health);
9628            } else {
9629                remote_models.push(health);
9630            }
9631        }
9632
9633        // Report the real managed-runtime state on every platform. Apple
9634        // Silicon used to fabricate `installed: true` with empty paths on the
9635        // theory that native MLX backends replaced the Python runtime — but
9636        // those backends can't load every catalogued speech checkpoint, so the
9637        // runtime is a live fallback there too and `car speech doctor` should
9638        // say whether it is actually present (Parslee-ai/car#640).
9639        let runtime = {
9640            let rt =
9641                SpeechRuntime::new(speech_runtime_root_from_models_dir(&self.config.models_dir));
9642            SpeechRuntimeHealth {
9643                root: rt.root.clone(),
9644                installed: rt.is_ready(),
9645                python: rt.python.clone(),
9646                stt_command: rt.stt_program.clone(),
9647                tts_command: rt.tts_program.clone(),
9648                configured_python: std::env::var("CAR_SPEECH_PYTHON")
9649                    .ok()
9650                    .filter(|value| !value.trim().is_empty()),
9651                detected_python: detect_speech_python(),
9652            }
9653        };
9654
9655        SpeechHealthReport {
9656            runtime,
9657            local_models,
9658            remote_models,
9659            // Passive doctor/health state may report explicit environment
9660            // presence, but must not query the OS credential store. A pasted
9661            // Keychain value remains unknown until an explicit use/status.
9662            elevenlabs_configured: crate::tasks::transcribe::provider_configured_for_passive_status(
9663                "ELEVENLABS_API_KEY",
9664            )
9665                || crate::tasks::synthesize::provider_configured_for_passive_status(
9666                    "ELEVENLABS_API_KEY",
9667                ),
9668            prefer_local: self.speech_policy.prefer_local,
9669            allow_remote_fallback: self.speech_policy.allow_remote_fallback,
9670            preferred_local_stt: self.speech_policy.preferred_local_stt.clone(),
9671            preferred_local_tts: self.speech_policy.preferred_local_tts.clone(),
9672            preferred_remote_stt: self.speech_policy.preferred_remote_stt.clone(),
9673            preferred_remote_tts: self.speech_policy.preferred_remote_tts.clone(),
9674            local_stt_default,
9675            local_tts_default,
9676            remote_stt_default,
9677            remote_tts_default,
9678        }
9679    }
9680
9681    /// Report the current model catalog, configured defaults, capability coverage,
9682    /// and speech runtime/provider health in one place.
9683    pub async fn model_health(&self) -> ModelHealthReport {
9684        let schemas = self.list_schemas();
9685        let total_models = schemas.len();
9686        let available_models = schemas
9687            .iter()
9688            .filter(|schema| schema.available_now())
9689            .count();
9690        let local_models = schemas.iter().filter(|schema| schema.is_local()).count();
9691        let remote_models = total_models.saturating_sub(local_models);
9692
9693        let defaults = vec![
9694            self.model_default_health(
9695                ModelCapability::Generate,
9696                self.preferred_model_for_capability(ModelCapability::Generate)
9697                    .unwrap_or(&self.config.generation_model),
9698            ),
9699            self.model_default_health(
9700                ModelCapability::Embed,
9701                self.preferred_model_for_capability(ModelCapability::Embed)
9702                    .unwrap_or(&self.config.embedding_model),
9703            ),
9704            self.model_default_health(
9705                ModelCapability::Classify,
9706                self.preferred_model_for_capability(ModelCapability::Classify)
9707                    .unwrap_or(&self.config.classification_model),
9708            ),
9709        ];
9710
9711        let mut providers = std::collections::BTreeMap::new();
9712        for schema in &schemas {
9713            let entry =
9714                providers
9715                    .entry(schema.provider.clone())
9716                    .or_insert_with(|| ProviderAccumulator {
9717                        configured: false,
9718                        local_models: 0,
9719                        remote_models: 0,
9720                        available_models: 0,
9721                        capabilities: std::collections::HashSet::new(),
9722                    });
9723
9724            entry.configured |= model_source_configured(schema);
9725            if schema.is_local() {
9726                entry.local_models += 1;
9727            } else {
9728                entry.remote_models += 1;
9729            }
9730            if schema.available_now() {
9731                entry.available_models += 1;
9732            }
9733            for capability in &schema.capabilities {
9734                entry.capabilities.insert(*capability);
9735            }
9736        }
9737
9738        let providers = providers
9739            .into_iter()
9740            .map(|(provider, acc)| ModelProviderHealth {
9741                provider,
9742                configured: acc.configured,
9743                local_models: acc.local_models,
9744                remote_models: acc.remote_models,
9745                available_models: acc.available_models,
9746                capabilities: sort_capabilities(acc.capabilities.into_iter().collect()),
9747            })
9748            .collect();
9749
9750        let capabilities = all_model_capabilities()
9751            .into_iter()
9752            .map(|capability| {
9753                let relevant: Vec<&ModelSchema> = schemas
9754                    .iter()
9755                    .filter(|schema| schema.has_capability(capability))
9756                    .collect();
9757                let available: Vec<&ModelSchema> = relevant
9758                    .iter()
9759                    .copied()
9760                    .filter(|schema| schema.available_now())
9761                    .collect();
9762                ModelCapabilityHealth {
9763                    capability,
9764                    total_models: relevant.len(),
9765                    available_models: available.len(),
9766                    local_available_models: available
9767                        .iter()
9768                        .filter(|schema| schema.is_local())
9769                        .count(),
9770                    remote_available_models: available
9771                        .iter()
9772                        .filter(|schema| !schema.is_local())
9773                        .count(),
9774                }
9775            })
9776            .collect();
9777
9778        let routing = self.routing_scenarios().await;
9779        let routing_config = self.adaptive_router.config().clone();
9780        let benchmark_priors =
9781            load_benchmark_prior_health(&self.config.state_models_dir(), &schemas);
9782
9783        ModelHealthReport {
9784            total_models,
9785            available_models,
9786            local_models,
9787            remote_models,
9788            defaults,
9789            providers,
9790            capabilities,
9791            routing_prefer_local: routing_config.prefer_local,
9792            routing_quality_first_cold_start: routing_config.quality_first_cold_start,
9793            routing_min_observations: routing_config.min_observations,
9794            routing_bootstrap_min_task_observations: routing_config.bootstrap_min_task_observations,
9795            routing_bootstrap_quality_floor: routing_config.bootstrap_quality_floor,
9796            routing_quality_weight: routing_config.quality_weight,
9797            routing_latency_weight: routing_config.latency_weight,
9798            routing_cost_weight: routing_config.cost_weight,
9799            routing_scenarios: routing,
9800            benchmark_priors,
9801            speech: self.speech_health(),
9802        }
9803    }
9804
9805    async fn routing_scenarios(&self) -> Vec<RoutingScenarioHealth> {
9806        let tracker = self.outcome_tracker.read().await;
9807        let config = self.adaptive_router.config().clone();
9808        let scenarios = [
9809            (
9810                "interactive_text",
9811                "Summarize the benefits of local-first AI routing in two sentences.",
9812                "text",
9813                RoutingWorkload::Interactive,
9814                false,
9815                false,
9816            ),
9817            (
9818                "background_code",
9819                "Write a Python function named fibonacci(n) that returns the nth Fibonacci number.",
9820                "code",
9821                RoutingWorkload::Background,
9822                false,
9823                false,
9824            ),
9825            (
9826                "interactive_tool_use",
9827                "Use the provided weather tool to get the weather for Boston.",
9828                "tool_use",
9829                RoutingWorkload::Interactive,
9830                true,
9831                false,
9832            ),
9833            (
9834                "interactive_vision",
9835                "What is in this image? Answer in one word.",
9836                "vision",
9837                RoutingWorkload::Interactive,
9838                false,
9839                true,
9840            ),
9841        ];
9842
9843        // Preview against the same live snapshot real routing uses, not the
9844        // construction-time registry. `self.unified_registry` is frozen at
9845        // engine construction — on the daemon's long-lived shared engine that
9846        // means a key connected or a model pulled since boot is invisible here,
9847        // so this health surface would claim a routing decision that differs
9848        // from what a real request now takes (the #651 staleness class).
9849        let routing_registry = self.catalog_registry_snapshot();
9850
9851        scenarios
9852            .into_iter()
9853            .map(
9854                |(name, prompt, task_family, workload, has_tools, has_vision)| {
9855                    let decision = self.adaptive_router.route_context_aware(
9856                        prompt,
9857                        0,
9858                        &routing_registry,
9859                        &tracker,
9860                        has_tools,
9861                        has_vision,
9862                        workload,
9863                    );
9864                    let quality_first_cold_start = if has_tools || has_vision {
9865                        config.quality_first_cold_start
9866                    } else if task_family == "code"
9867                        && matches!(workload, RoutingWorkload::Background)
9868                    {
9869                        false
9870                    } else {
9871                        config.quality_first_cold_start
9872                    };
9873                    RoutingScenarioHealth {
9874                        name: name.to_string(),
9875                        task_family: task_family.to_string(),
9876                        workload,
9877                        has_tools,
9878                        has_vision,
9879                        prefer_local: if task_family == "speech" {
9880                            self.speech_policy.prefer_local
9881                        } else {
9882                            config.prefer_local
9883                        },
9884                        quality_first_cold_start,
9885                        bootstrap_min_task_observations: config.bootstrap_min_task_observations,
9886                        bootstrap_quality_floor: config.bootstrap_quality_floor,
9887                        model_id: decision.model_id,
9888                        model_name: decision.model_name,
9889                        reason: decision.reason,
9890                        strategy: decision.strategy,
9891                    }
9892                },
9893            )
9894            .collect()
9895    }
9896
9897    /// Run a real speech smoke test through the configured local and/or remote paths.
9898    pub async fn smoke_test_speech(
9899        &self,
9900        local: bool,
9901        remote: bool,
9902    ) -> Result<SpeechSmokeReport, InferenceError> {
9903        let mut report = SpeechSmokeReport::default();
9904
9905        if local {
9906            let tts = self
9907                .preferred_speech_schema(ModelCapability::TextToSpeech, true, false)
9908                .ok_or_else(|| {
9909                    InferenceError::InferenceFailed(
9910                        "no local text-to-speech model available".into(),
9911                    )
9912                })?;
9913            let stt = self
9914                .preferred_speech_schema(ModelCapability::SpeechToText, true, false)
9915                .ok_or_else(|| {
9916                    InferenceError::InferenceFailed(
9917                        "no local speech-to-text model available".into(),
9918                    )
9919                })?;
9920            report.local = Some(
9921                self.run_speech_smoke_path("local", &tts, &stt, "Testing CAR local speech path.")
9922                    .await?,
9923            );
9924        } else {
9925            report.skipped.push("local".to_string());
9926        }
9927
9928        if remote {
9929            let tts = self
9930                .preferred_speech_schema(ModelCapability::TextToSpeech, false, true)
9931                .ok_or_else(|| {
9932                    InferenceError::InferenceFailed(
9933                        "no remote text-to-speech model available".into(),
9934                    )
9935                })?;
9936            let stt = self
9937                .preferred_speech_schema(ModelCapability::SpeechToText, false, true)
9938                .ok_or_else(|| {
9939                    InferenceError::InferenceFailed(
9940                        "no remote speech-to-text model available".into(),
9941                    )
9942                })?;
9943            report.remote = Some(
9944                self.run_speech_smoke_path("remote", &tts, &stt, "Testing CAR remote speech path.")
9945                    .await?,
9946            );
9947        } else {
9948            report.skipped.push("remote".to_string());
9949        }
9950
9951        Ok(report)
9952    }
9953
9954    fn speech_candidates(
9955        &self,
9956        capability: ModelCapability,
9957        explicit: Option<&str>,
9958    ) -> Result<Vec<ModelSchema>, InferenceError> {
9959        if let Some(model) = explicit {
9960            let schema = self
9961                .unified_registry
9962                .get(model)
9963                .or_else(|| self.unified_registry.find_by_name(model))
9964                .cloned()
9965                .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?;
9966            if !schema.has_capability(capability) {
9967                return Err(InferenceError::InferenceFailed(format!(
9968                    "model {} does not support {:?}",
9969                    schema.name, capability
9970                )));
9971            }
9972            return Ok(vec![schema]);
9973        }
9974
9975        let mut candidates: Vec<ModelSchema> = self
9976            .unified_registry
9977            .query(&ModelFilter {
9978                capabilities: vec![capability],
9979                ..Default::default()
9980            })
9981            .into_iter()
9982            .cloned()
9983            .collect();
9984
9985        if candidates.is_empty() {
9986            return Err(InferenceError::InferenceFailed(format!(
9987                "no models registered for capability {:?}",
9988                capability
9989            )));
9990        }
9991
9992        candidates.sort_by_key(|model| self.speech_sort_key(capability, model));
9993        if !self.speech_policy.allow_remote_fallback
9994            && candidates.iter().any(|model| model.is_local())
9995        {
9996            candidates.retain(|model| model.is_local());
9997        }
9998
9999        Ok(candidates)
10000    }
10001
10002    /// Resolve a car-canonical model id (e.g. `mlx/flux-1-lite-8b:q4`) to the
10003    /// HuggingFace repo (`mlx-community/Flux-1.lite-8B-MLX-Q4`) that the
10004    /// external Python CLIs expect. Falls back to the input if no schema
10005    /// matches or the schema is not MLX-sourced.
10006    #[allow(dead_code)] // conditionally compiled — used only on external-agent HF resolution paths
10007    fn resolve_external_hf_repo(
10008        &self,
10009        explicit: Option<&str>,
10010        capability: ModelCapability,
10011    ) -> Option<String> {
10012        let id = explicit?;
10013        let schema = self
10014            .unified_registry
10015            .get(id)
10016            .or_else(|| self.unified_registry.find_by_name(id))?;
10017        if !schema.has_capability(capability) {
10018            return Some(id.to_string());
10019        }
10020        if let ModelSource::Mlx { hf_repo, .. } = &schema.source {
10021            return Some(hf_repo.clone());
10022        }
10023        Some(id.to_string())
10024    }
10025
10026    fn media_generation_candidates(
10027        &self,
10028        capability: ModelCapability,
10029        explicit: Option<&str>,
10030    ) -> Result<Vec<ModelSchema>, InferenceError> {
10031        if let Some(model) = explicit {
10032            let schema = self
10033                .unified_registry
10034                .get(model)
10035                .or_else(|| self.unified_registry.find_by_name(model))
10036                .cloned()
10037                .ok_or_else(|| InferenceError::ModelNotFound(model.to_string()))?;
10038            if !schema.has_capability(capability) {
10039                return Err(InferenceError::InferenceFailed(format!(
10040                    "model {} does not support {:?}",
10041                    schema.name, capability
10042                )));
10043            }
10044            return Ok(vec![schema]);
10045        }
10046
10047        let mut candidates: Vec<ModelSchema> = self
10048            .unified_registry
10049            .query(&ModelFilter {
10050                capabilities: vec![capability],
10051                local_only: true,
10052                ..Default::default()
10053            })
10054            .into_iter()
10055            .cloned()
10056            .collect();
10057        candidates.sort_by_key(|schema| (!schema.available, schema.size_mb()));
10058        if candidates.is_empty() {
10059            return Err(InferenceError::InferenceFailed(format!(
10060                "no models registered for capability {:?}",
10061                capability
10062            )));
10063        }
10064        Ok(candidates)
10065    }
10066
10067    fn preferred_speech_schema(
10068        &self,
10069        capability: ModelCapability,
10070        local_only: bool,
10071        remote_only: bool,
10072    ) -> Option<ModelSchema> {
10073        let available_only = remote_only;
10074        let mut candidates: Vec<ModelSchema> = self
10075            .unified_registry
10076            .query(&ModelFilter {
10077                capabilities: vec![capability],
10078                available_only,
10079                ..Default::default()
10080            })
10081            .into_iter()
10082            .filter(|schema| {
10083                (!local_only || schema.is_local()) && (!remote_only || schema.is_remote())
10084            })
10085            .cloned()
10086            .collect();
10087        candidates.sort_by_key(|model| self.speech_sort_key(capability, model));
10088        candidates.into_iter().next()
10089    }
10090
10091    fn speech_health_default_name(
10092        &self,
10093        capability: ModelCapability,
10094        local_only: bool,
10095        remote_only: bool,
10096    ) -> Option<String> {
10097        let preferred = match capability {
10098            ModelCapability::SpeechToText if local_only => {
10099                self.speech_policy.preferred_local_stt.as_ref()
10100            }
10101            ModelCapability::SpeechToText if remote_only => {
10102                self.speech_policy.preferred_remote_stt.as_ref()
10103            }
10104            ModelCapability::TextToSpeech if local_only => {
10105                self.speech_policy.preferred_local_tts.as_ref()
10106            }
10107            ModelCapability::TextToSpeech if remote_only => {
10108                self.speech_policy.preferred_remote_tts.as_ref()
10109            }
10110            _ => None,
10111        };
10112
10113        preferred
10114            .filter(|name| {
10115                self.unified_registry.list().iter().any(|schema| {
10116                    schema.name == **name
10117                        && schema.has_capability(capability)
10118                        && (!local_only || schema.is_local())
10119                        && (!remote_only || schema.is_remote())
10120                })
10121            })
10122            .cloned()
10123            .or_else(|| {
10124                self.preferred_speech_schema(capability, local_only, remote_only)
10125                    .map(|schema| schema.name)
10126            })
10127    }
10128
10129    fn model_default_health(
10130        &self,
10131        capability: ModelCapability,
10132        configured_model: &str,
10133    ) -> ModelDefaultHealth {
10134        let schema = self
10135            .unified_registry
10136            .find_by_name(configured_model)
10137            .or_else(|| self.unified_registry.get(configured_model));
10138
10139        ModelDefaultHealth {
10140            capability,
10141            configured_model: configured_model.to_string(),
10142            available: schema.is_some_and(ModelSchema::available_now),
10143            is_local: schema.is_some_and(ModelSchema::is_local),
10144            provider: schema.map(|model| model.provider.clone()),
10145        }
10146    }
10147
10148    fn speech_sort_key(
10149        &self,
10150        capability: ModelCapability,
10151        model: &ModelSchema,
10152    ) -> (u8, u8, u8, u8, u64, u64) {
10153        let policy_preference = match capability {
10154            ModelCapability::SpeechToText if model.is_local() => {
10155                self.speech_policy.preferred_local_stt.as_ref()
10156            }
10157            ModelCapability::SpeechToText => self.speech_policy.preferred_remote_stt.as_ref(),
10158            ModelCapability::TextToSpeech if model.is_local() => {
10159                self.speech_policy.preferred_local_tts.as_ref()
10160            }
10161            ModelCapability::TextToSpeech => self.speech_policy.preferred_remote_tts.as_ref(),
10162            _ => None,
10163        };
10164        let local_rank = if self.speech_policy.prefer_local {
10165            if model.is_local() {
10166                0
10167            } else {
10168                1
10169            }
10170        } else if model.is_remote() {
10171            0
10172        } else {
10173            1
10174        };
10175        let availability_rank = if model.available {
10176            0
10177        } else if model.is_local() {
10178            1
10179        } else {
10180            2
10181        };
10182        let policy_rank: u8 = if policy_preference.is_some_and(|preferred| preferred == &model.name)
10183        {
10184            0
10185        } else {
10186            1
10187        };
10188        let speech_rank = match capability {
10189            // Kokoro first, deliberately. `Qwen3-TTS-12Hz-1.7B-Base-5bit` used
10190            // to rank 0 here, but CAR has **no Qwen3-TTS backend** — the only
10191            // local MLX TTS loaders are `backend::mlx_kokoro` and
10192            // `backend::mlx_parakeet`, and the TTS path calls
10193            // `KokoroBackend::load` unconditionally. Preferring Qwen3-TTS
10194            // therefore fed Qwen3 weights to Kokoro's architecture and every
10195            // synthesis died on `missing tensor:
10196            // bert.embeddings.word_embeddings.weight`, so local TTS never
10197            // worked at all (Parslee-ai/car#640).
10198            //
10199            // Ranking follows what can actually be loaded. Restore Qwen3-TTS to
10200            // the front when a backend for it exists — its advanced controls
10201            // (voice cloning, `voice_instruction`) are already modelled in
10202            // `SynthesizeRequest` and are worth preferring once loadable.
10203            ModelCapability::TextToSpeech => {
10204                if model.name == "Kokoro-82M-bf16" {
10205                    0
10206                } else if model.name == "Kokoro-82M-6bit" {
10207                    1
10208                } else if model.name == "Qwen3-TTS-12Hz-1.7B-Base-5bit" {
10209                    // Last among curated TTS: cataloged and downloadable, but
10210                    // not loadable until it has a backend.
10211                    3
10212                } else {
10213                    2
10214                }
10215            }
10216            ModelCapability::SpeechToText => {
10217                if model.name == "Parakeet-TDT-0.6B-v3-MLX" {
10218                    0
10219                } else {
10220                    1
10221                }
10222            }
10223            _ => 0,
10224        };
10225        let latency_rank = model.performance.latency_p50_ms.unwrap_or(u64::MAX);
10226        let size_rank = model.cost.size_mb.unwrap_or(u64::MAX);
10227        (
10228            local_rank,
10229            availability_rank,
10230            policy_rank,
10231            speech_rank,
10232            latency_rank,
10233            size_rank,
10234        )
10235    }
10236
10237    async fn run_speech_smoke_path(
10238        &self,
10239        path: &str,
10240        tts: &ModelSchema,
10241        stt: &ModelSchema,
10242        text: &str,
10243    ) -> Result<SpeechSmokePathReport, InferenceError> {
10244        let work_dir = temp_work_dir(&format!("speech-smoke-{path}"))?;
10245        let audio_path = work_dir.join(format!("{path}.wav"));
10246        let synth = self
10247            .synthesize(SynthesizeRequest {
10248                text: text.to_string(),
10249                model: Some(tts.name.clone()),
10250                voice: default_speech_voice(tts),
10251                language: Some("en".to_string()),
10252                output_path: Some(audio_path.display().to_string()),
10253                ..SynthesizeRequest::default()
10254            })
10255            .await?;
10256        let transcript = self
10257            .transcribe(TranscribeRequest {
10258                audio_path: synth.audio_path.clone(),
10259                model: Some(stt.name.clone()),
10260                language: Some("en".to_string()),
10261                prompt: None,
10262                timestamps: false,
10263            })
10264            .await?;
10265
10266        Ok(SpeechSmokePathReport {
10267            path: path.to_string(),
10268            tts_model: synth.model_used.unwrap_or_else(|| tts.name.clone()),
10269            stt_model: transcript.model_used.unwrap_or_else(|| stt.name.clone()),
10270            audio_path: PathBuf::from(synth.audio_path),
10271            transcript: transcript.text,
10272        })
10273    }
10274
10275    async fn ensure_speech_runtime(&self) -> Result<SpeechRuntime, InferenceError> {
10276        let mut guard = self.speech_runtime.lock().await;
10277        if let Some(runtime) = guard.as_ref() {
10278            if runtime.is_ready() {
10279                return Ok(runtime.clone());
10280            }
10281        }
10282
10283        let runtime =
10284            SpeechRuntime::new(speech_runtime_root_from_models_dir(&self.config.models_dir));
10285        if !runtime.is_ready() {
10286            bootstrap_speech_runtime(&runtime).await?;
10287        }
10288        if !runtime.is_ready() {
10289            return Err(InferenceError::InferenceFailed(format!(
10290                "managed speech runtime is not ready at {}",
10291                runtime.root.display()
10292            )));
10293        }
10294
10295        *guard = Some(runtime.clone());
10296        Ok(runtime)
10297    }
10298
10299    /// Transcribe an audio file with the in-process whisper.cpp backend — the
10300    /// cross-platform on-device STT (`ModelSource::WhisperCpp`) that the `car
10301    /// speech` catalog offers where MLX isn't available. The ggml model
10302    /// lazy-downloads on first use via `car-whisper`. Runs on a blocking pool
10303    /// (whisper.cpp is synchronous + CPU/GPU-bound).
10304    ///
10305    /// NB: loads the model per call for now — a resident-context cache is a
10306    /// follow-up; the catalog STT path is setup/smoke/occasional, not hot.
10307    async fn transcribe_whisper(
10308        &self,
10309        schema: &ModelSchema,
10310        model: &str,
10311        req: &TranscribeRequest,
10312        reservation: Option<&mut resource_policy::LocalLoadReservation>,
10313    ) -> Result<TranscribeResult, InferenceError> {
10314        let model = model.to_string();
10315        let model_path = car_whisper::ensure_model(&model)
10316            .map_err(|e| InferenceError::InferenceFailed(format!("whisper download: {e}")))?;
10317        let measured_bytes = backend_cache::estimate_model_size(&model_path);
10318        let reservation = reservation.ok_or_else(|| {
10319            InferenceError::InferenceFailed("local Whisper path missing admission".into())
10320        })?;
10321        reservation
10322            .reconcile_measured_weights(measured_bytes)
10323            .map_err(InferenceError::from)?;
10324        let detached_lease = reservation.detached_lease();
10325        // whisper.cpp accepts "auto" for language auto-detection.
10326        let language = req.language.clone().unwrap_or_else(|| "auto".to_string());
10327        let audio_path = std::path::PathBuf::from(&req.audio_path);
10328        let name = schema.name.clone();
10329        let req_language = req.language.clone();
10330        let text =
10331            run_admitted_blocking(detached_lease, move || -> Result<String, InferenceError> {
10332                // LOCAL_ADMISSION_BOUNDARY:speech-stt-dispatch
10333                let stt = car_whisper::WhisperStt::load_from_path(&model_path, &language)
10334                    .map_err(|e| InferenceError::InferenceFailed(format!("whisper load: {e}")))?;
10335                stt.transcribe_file(&audio_path).map_err(|e| {
10336                    InferenceError::InferenceFailed(format!("whisper transcribe: {e}"))
10337                })
10338            })
10339            .await
10340            .map_err(|e| InferenceError::InferenceFailed(format!("whisper join: {e}")))??;
10341        Ok(TranscribeResult::text_only(text, Some(name), req_language))
10342    }
10343
10344    async fn transcribe_local_mlx(
10345        &self,
10346        schema: &ModelSchema,
10347        req: &TranscribeRequest,
10348        reservation: Option<&mut resource_policy::LocalLoadReservation>,
10349    ) -> Result<TranscribeResult, InferenceError> {
10350        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
10351        let reservation = reservation.ok_or_else(|| {
10352            InferenceError::InferenceFailed("local STT path missing admission".into())
10353        })?;
10354        reservation
10355            .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
10356            .map_err(InferenceError::from)?;
10357        // Native MLX transcription via Parakeet backend (no Python shelling).
10358        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
10359        {
10360            // Same story as TTS: the native backend can't load every catalogued
10361            // STT checkpoint, so hand off to the managed mlx-audio runtime
10362            // instead of failing outright (Parslee-ai/car#640).
10363            // LOCAL_ADMISSION_BOUNDARY:speech-stt-dispatch
10364            let parakeet = match backend::mlx_parakeet::ParakeetBackend::load(&model_dir) {
10365                Ok(p) => p,
10366                Err(native_err) => {
10367                    tracing::info!(
10368                        model = %schema.name,
10369                        error = %native_err,
10370                        "native MLX STT backend can't load this model; \
10371                         falling back to the managed mlx-audio runtime"
10372                    );
10373                    return self
10374                        .transcribe_via_speech_runtime(schema, req, reservation.detached_lease())
10375                        .await
10376                        .map_err(|runtime_err| {
10377                            InferenceError::InferenceFailed(format!(
10378                                "native MLX backend failed ({native_err}); \
10379                                 mlx-audio runtime fallback also failed ({runtime_err}). \
10380                                 Install the runtime with `car speech install`."
10381                            ))
10382                        });
10383                }
10384            };
10385            // Only pay the word-grouping cost when the caller asked.
10386            let (text, words) = if req.timestamps {
10387                parakeet
10388                    .transcribe_detailed(Path::new(&req.audio_path))
10389                    .map_err(|e| InferenceError::InferenceFailed(format!("native STT: {e}")))?
10390            } else {
10391                let t = parakeet
10392                    .transcribe(Path::new(&req.audio_path))
10393                    .map_err(|e| InferenceError::InferenceFailed(format!("native STT: {e}")))?;
10394                (t, Vec::new())
10395            };
10396            Ok(TranscribeResult {
10397                text,
10398                model_used: Some(schema.name.clone()),
10399                language: req.language.clone(),
10400                words,
10401                routing_explanation: None,
10402            })
10403        }
10404
10405        // Non-Apple-Silicon: the Python speech runtime is the only path.
10406        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
10407        {
10408            self.transcribe_via_speech_runtime(schema, req, reservation.detached_lease())
10409                .await
10410        }
10411    }
10412
10413    /// Transcribe through the managed `mlx-audio` Python runtime.
10414    ///
10415    /// Twin of [`Self::synthesize_via_speech_runtime`], and available on every
10416    /// platform for the same reason: the native MLX Parakeet backend cannot load
10417    /// the shipped checkpoint (`missing tensor: encoder.layers.0.ff1.norm.weight`),
10418    /// so without this local STT is dead on Apple Silicon (Parslee-ai/car#640).
10419    async fn transcribe_via_speech_runtime(
10420        &self,
10421        schema: &ModelSchema,
10422        req: &TranscribeRequest,
10423        detached_lease: resource_policy::DetachedLocalLease,
10424    ) -> Result<TranscribeResult, InferenceError> {
10425        {
10426            let runtime = self.ensure_speech_runtime().await?;
10427            let hf_repo = match &schema.source {
10428                ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
10429                _ => {
10430                    return Err(InferenceError::InferenceFailed(format!(
10431                        "speech runtime needs an MLX model repo; {} is not one",
10432                        schema.id
10433                    )))
10434                }
10435            };
10436            let output_dir = temp_work_dir("stt")?;
10437            let output_prefix = output_dir.join("transcript");
10438            let mut args = vec![
10439                "--model".to_string(),
10440                hf_repo,
10441                "--audio".to_string(),
10442                req.audio_path.clone(),
10443                "--output-path".to_string(),
10444                output_prefix.display().to_string(),
10445                "--format".to_string(),
10446                "json".to_string(),
10447            ];
10448            if let Some(language) = &req.language {
10449                args.push("--language".to_string());
10450                args.push(normalize_lang_code(language));
10451            }
10452            if let Some(prompt) = &req.prompt {
10453                args.push("--context".to_string());
10454                args.push(prompt.clone());
10455            }
10456            if req.timestamps {
10457                args.push("--verbose".to_string());
10458            }
10459
10460            let output =
10461                run_mlx_audio_command(&runtime, "stt.generate", &args, detached_lease).await?;
10462            let text = read_transcription_result(&output_prefix)?
10463                .or_else(|| extract_text_from_payload(&output.stdout))
10464                .ok_or_else(|| {
10465                    InferenceError::InferenceFailed(format!(
10466                        "mlx-audio transcription returned no text: {}",
10467                        output.stderr
10468                    ))
10469                })?;
10470
10471            Ok(TranscribeResult {
10472                text,
10473                model_used: Some(schema.name.clone()),
10474                language: req.language.clone(),
10475                words: Vec::new(),
10476                routing_explanation: None,
10477            })
10478        }
10479    }
10480
10481    async fn synthesize_local_mlx(
10482        &self,
10483        schema: &ModelSchema,
10484        req: &SynthesizeRequest,
10485        reservation: Option<&mut resource_policy::LocalLoadReservation>,
10486    ) -> Result<SynthesizeResult, InferenceError> {
10487        // Single entry-point check for Qwen3-TTS advanced controls.
10488        // Hoisted here so that a Kokoro → Kokoro-bf16 fallback chain
10489        // doesn't double-warn, and so strict callers get one clean
10490        // error instead of being lied to by partial success.
10491        let requested = req.requested_advanced_controls();
10492        let repo_supports_advanced = match &schema.source {
10493            ModelSource::Mlx { hf_repo, .. } => hf_repo.to_ascii_lowercase().contains("qwen3-tts"),
10494            _ => false,
10495        };
10496        if !requested.is_empty() && !repo_supports_advanced {
10497            if req.strict_capabilities {
10498                return Err(InferenceError::InferenceFailed(format!(
10499                    "model {name} does not support Qwen3-TTS advanced controls {requested:?}; \
10500                     route to a Qwen3-TTS model or set strict_capabilities = false to degrade",
10501                    name = schema.name,
10502                )));
10503            }
10504            tracing::warn!(
10505                model = %schema.name,
10506                fields = ?requested,
10507                "Qwen3-TTS advanced controls set on non-Qwen3-TTS backend — ignored \
10508                 (set strict_capabilities=true to error instead)"
10509            );
10510        }
10511
10512        let model_dir = self.unified_registry.ensure_local(&schema.id).await?;
10513        let reservation = reservation.ok_or_else(|| {
10514            InferenceError::InferenceFailed("local TTS path missing admission".into())
10515        })?;
10516        reservation
10517            .reconcile_measured_weights(backend_cache::estimate_model_size(&model_dir))
10518            .map_err(InferenceError::from)?;
10519
10520        // Native MLX synthesis via Kokoro backend (no Python shelling).
10521        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
10522        {
10523            // The native Apple-Silicon path is Kokoro-only today; a
10524            // Qwen3-TTS schema would have routed here but the backend
10525            // has no cloning support yet. Strict callers are already
10526            // stopped above; degrade-ok callers get a second, narrower
10527            // note about the native-vs-Python capability gap.
10528            if repo_supports_advanced && !requested.is_empty() {
10529                if req.strict_capabilities {
10530                    return Err(InferenceError::InferenceFailed(format!(
10531                        "native MLX TTS backend does not yet implement Qwen3-TTS advanced \
10532                         controls {requested:?}; run on non-Apple-Silicon to use the Python \
10533                         mlx-audio fallback, or set strict_capabilities = false"
10534                    )));
10535                }
10536                tracing::warn!(
10537                    model = %schema.name,
10538                    fields = ?requested,
10539                    "Qwen3-TTS advanced controls are not yet implemented in the native MLX TTS \
10540                     backend; synthesizing without cloning/voice-design"
10541                );
10542            }
10543            let size = backend_cache::estimate_model_size(&model_dir);
10544            let cache_key = reservation.model_id().to_string();
10545            // LOCAL_ADMISSION_BOUNDARY:speech-tts-dispatch
10546            let (handle, retention) = match Self::load_backend_healing(
10547                &cache_key,
10548                model_dir,
10549                &self.kokoro_cache,
10550                size,
10551                reservation,
10552                backend::mlx_kokoro::KokoroBackend::load,
10553                || self.unified_registry.redownload_local(&schema.id),
10554            )
10555            .await
10556            {
10557                Ok(handle) => handle,
10558                // The native MLX backend can't serve every catalogued TTS model
10559                // — it implements a plain iSTFTNet vocoder, while Kokoro's
10560                // shipped checkpoint is StyleTTS2 and Qwen3-TTS has no backend
10561                // at all (Parslee-ai/car#640). Rather than fail outright, hand
10562                // off to the managed `mlx-audio` runtime, which is upstream's
10563                // own implementation and loads all of them. Mirrors the
10564                // native/external split `generate_image` uses for Flux.
10565                Err(native_err) => {
10566                    tracing::info!(
10567                        model = %schema.name,
10568                        error = %native_err,
10569                        "native MLX TTS backend can't load this model; \
10570                         falling back to the managed mlx-audio runtime"
10571                    );
10572                    return self
10573                        .synthesize_via_speech_runtime(schema, req, reservation.detached_lease())
10574                        .await
10575                        .map_err(|runtime_err| {
10576                            InferenceError::InferenceFailed(format!(
10577                                "native MLX backend failed ({native_err}); \
10578                                 mlx-audio runtime fallback also failed ({runtime_err}). \
10579                                 Install the runtime with `car speech install`."
10580                            ))
10581                        });
10582                }
10583            };
10584
10585            let output_path = req.output_path.clone().unwrap_or_else(|| {
10586                let dir = std::env::temp_dir().join("car_tts");
10587                let _ = std::fs::create_dir_all(&dir);
10588                dir.join("output.wav").display().to_string()
10589            });
10590            let voice = req.voice.as_deref().unwrap_or("af_heart").to_string();
10591            let text = req.text.clone();
10592            let detached_lease = (retention == backend_cache::BackendRetention::Transient)
10593                .then(|| reservation.detached_lease());
10594            // Serialize on the shared Metal device (see `mlx_device_lock`): a
10595            // kokoro eval concurrent with a flux/ltx eval races the command
10596            // encoder and segfaults the process. The per-model `handle.lock()`
10597            // alone does not prevent a cross-model device race. Held inside the
10598            // blocking closure so it survives request-deadline abandonment.
10599            let device_guard = Self::mlx_device_lock().lock_owned().await;
10600            let op = tokio::task::spawn_blocking(move || -> Result<PathBuf, InferenceError> {
10601                let _detached_lease = detached_lease;
10602                let _device_guard = device_guard;
10603                let mut guard = handle.lock().map_err(|_| {
10604                    InferenceError::InferenceFailed("kokoro backend mutex poisoned".into())
10605                })?;
10606                guard
10607                    .synthesize(&text, Some(&voice), Path::new(&output_path))
10608                    .map_err(|e| InferenceError::InferenceFailed(format!("native TTS: {e}")))
10609            })
10610            .await
10611            .map_err(|e| InferenceError::InferenceFailed(format!("kokoro task join: {e}")))??;
10612
10613            let final_path =
10614                materialize_audio_output(&op, req.output_path.as_deref(), &req.format)?;
10615            Ok(SynthesizeResult {
10616                audio_path: final_path.display().to_string(),
10617                media_type: media_type_for_format(&req.format),
10618                model_used: Some(schema.name.clone()),
10619                voice_used: req.voice.clone(),
10620                routing_explanation: None,
10621            })
10622        }
10623
10624        // Non-Apple-Silicon: the Python speech runtime is the only path.
10625        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
10626        {
10627            self.synthesize_via_speech_runtime(schema, req, reservation.detached_lease())
10628                .await
10629        }
10630    }
10631
10632    /// Synthesize through the managed `mlx-audio` Python runtime.
10633    ///
10634    /// This is upstream's own Kokoro implementation, so it is the reference for
10635    /// what local TTS should sound like. It was the only path on non-Apple
10636    /// platforms and was compiled out on Apple Silicon, which assumed the native
10637    /// MLX backend worked; that backend implements a plain iSTFTNet vocoder while
10638    /// Kokoro is StyleTTS2 (AdaIN conditioning, Snake activations, a
10639    /// harmonic-plus-noise source), so it can't load the shipped checkpoint and
10640    /// local TTS was dead on macOS (Parslee-ai/car#640). Now available on every
10641    /// platform, as the fallback when the native backend can't serve a model —
10642    /// the same native/external split `generate_image` already uses for Flux
10643    /// vs. mflux.
10644    async fn synthesize_via_speech_runtime(
10645        &self,
10646        schema: &ModelSchema,
10647        req: &SynthesizeRequest,
10648        detached_lease: resource_policy::DetachedLocalLease,
10649    ) -> Result<SynthesizeResult, InferenceError> {
10650        let runtime = self.ensure_speech_runtime().await?;
10651        let primary_hf_repo = match &schema.source {
10652            ModelSource::Mlx { hf_repo, .. } => hf_repo.clone(),
10653            _ => {
10654                return Err(InferenceError::InferenceFailed(format!(
10655                    "speech runtime needs an MLX model repo; {} is not one",
10656                    schema.id
10657                )))
10658            }
10659        };
10660        let (produced, model_used) = match self
10661            .synthesize_local_mlx_repo(
10662                &runtime,
10663                &primary_hf_repo,
10664                schema.name.as_str(),
10665                req,
10666                detached_lease.clone(),
10667            )
10668            .await
10669        {
10670            Ok(result) => result,
10671            Err(primary_err)
10672                if primary_hf_repo == "mlx-community/Kokoro-82M-6bit"
10673                    && kokoro_runtime_fallback_enabled() =>
10674            {
10675                let fallback_repo = "mlx-community/Kokoro-82M-bf16";
10676                let fallback_name = "Kokoro-82M-bf16";
10677                match self
10678                    .synthesize_local_mlx_repo(
10679                        &runtime,
10680                        fallback_repo,
10681                        fallback_name,
10682                        req,
10683                        detached_lease.clone(),
10684                    )
10685                    .await
10686                {
10687                    Ok(result) => result,
10688                    Err(fallback_err) => {
10689                        return Err(InferenceError::InferenceFailed(format!(
10690                            "{primary_err}; fallback {fallback_name} also failed: {fallback_err}"
10691                        )));
10692                    }
10693                }
10694            }
10695            Err(err) => return Err(err),
10696        };
10697        let final_path =
10698            materialize_audio_output(&produced, req.output_path.as_deref(), &req.format)?;
10699
10700        Ok(SynthesizeResult {
10701            audio_path: final_path.display().to_string(),
10702            media_type: media_type_for_format(&req.format),
10703            model_used: Some(model_used),
10704            voice_used: req.voice.clone(),
10705            routing_explanation: None,
10706        })
10707    }
10708
10709    async fn synthesize_local_mlx_repo(
10710        &self,
10711        runtime: &SpeechRuntime,
10712        hf_repo: &str,
10713        model_name: &str,
10714        req: &SynthesizeRequest,
10715        detached_lease: resource_policy::DetachedLocalLease,
10716    ) -> Result<(PathBuf, String), InferenceError> {
10717        let output_dir = temp_work_dir("tts")?;
10718        let mut args = vec![
10719            "--model".to_string(),
10720            hf_repo.to_string(),
10721            "--text".to_string(),
10722            req.text.clone(),
10723            "--output_path".to_string(),
10724            output_dir.display().to_string(),
10725        ];
10726        if let Some(voice) = &req.voice {
10727            args.push("--voice".to_string());
10728            args.push(voice.clone());
10729        }
10730        if let Some(speed) = req.speed {
10731            args.push("--speed".to_string());
10732            args.push(speed.to_string());
10733        }
10734        let repo_lower = hf_repo.to_ascii_lowercase();
10735        if repo_lower.contains("kokoro") {
10736            args.push("--lang_code".to_string());
10737            args.push(kokoro_lang_code(req.language.as_deref()).to_string());
10738        } else if let Some(language) = &req.language {
10739            args.push("--lang_code".to_string());
10740            args.push(normalize_lang_code(language));
10741        }
10742
10743        // Qwen3-TTS advanced controls — reference-audio cloning and
10744        // voice-design natural-language instruction. The
10745        // supported/unsupported decision was already made at the
10746        // `synthesize_local_mlx` entry point; here we only need to
10747        // forward the flags to the mlx-audio CLI for Qwen3-TTS repos.
10748        if repo_lower.contains("qwen3-tts") {
10749            if let Some(ref_audio) = &req.reference_audio_path {
10750                args.push("--ref_audio".to_string());
10751                args.push(ref_audio.clone());
10752            }
10753            if let Some(ref_text) = &req.reference_text {
10754                args.push("--ref_text".to_string());
10755                args.push(ref_text.clone());
10756            }
10757            if let Some(instruct) = &req.voice_instruction {
10758                args.push("--instruct".to_string());
10759                args.push(instruct.clone());
10760            }
10761        }
10762
10763        let output = if repo_lower.contains("kokoro") {
10764            let device = std::env::var("CAR_SPEECH_KOKORO_DEVICE")
10765                .or_else(|_| std::env::var("CAR_SPEECH_MLX_DEVICE"))
10766                .unwrap_or_else(|_| "cpu".to_string());
10767            let extra_env = vec![
10768                // Force MLX device (defaults to CPU to avoid Metal/NSRangeException crashes)
10769                ("MLX_DEVICE".to_string(), device),
10770                // Prevent MPS/Metal kernel crashes by enabling CPU fallback
10771                ("PYTORCH_ENABLE_MPS_FALLBACK".to_string(), "1".to_string()),
10772            ];
10773            run_mlx_audio_command_with_env(
10774                runtime,
10775                "tts.generate",
10776                &args,
10777                &extra_env,
10778                detached_lease,
10779            )
10780            .await?
10781        } else {
10782            run_mlx_audio_command(runtime, "tts.generate", &args, detached_lease).await?
10783        };
10784        let produced = find_audio_file(&output_dir)?.ok_or_else(|| {
10785            let hint = if repo_lower.contains("kokoro") {
10786                ". Kokoro models may crash on GPU — try CAR_SPEECH_KOKORO_DEVICE=cpu or use the default Qwen3-TTS model"
10787            } else {
10788                ""
10789            };
10790            InferenceError::InferenceFailed(format!(
10791                "mlx-audio synthesis produced no audio file: {}{}",
10792                output.stderr, hint
10793            ))
10794        })?;
10795        Ok((produced, model_name.to_string()))
10796    }
10797
10798    async fn transcribe_elevenlabs(
10799        &self,
10800        schema: &ModelSchema,
10801        req: &TranscribeRequest,
10802    ) -> Result<TranscribeResult, InferenceError> {
10803        let (endpoint, api_key) = elevenlabs_auth(
10804            schema,
10805            crate::tasks::transcribe::resolve_provider_credential_for_request,
10806        )?;
10807        let file_name = Path::new(&req.audio_path)
10808            .file_name()
10809            .and_then(|f| f.to_str())
10810            .unwrap_or("audio.wav")
10811            .to_string();
10812        let audio_bytes = tokio::fs::read(&req.audio_path).await?;
10813        let file_part = Part::bytes(audio_bytes).file_name(file_name);
10814        let mut form = Form::new()
10815            .text("model_id", schema.name.clone())
10816            .part("file", file_part);
10817        if let Some(language) = &req.language {
10818            form = form.text("language_code", language.clone());
10819        }
10820
10821        let resp = self
10822            .remote_backend
10823            .client
10824            .post(format!(
10825                "{}/v1/speech-to-text",
10826                endpoint.trim_end_matches('/')
10827            ))
10828            .header("xi-api-key", api_key)
10829            .multipart(form)
10830            .send()
10831            .await
10832            .map_err(|e| {
10833                self.remote_backend
10834                    .request_error("ElevenLabs STT request failed", &e)
10835            })?;
10836        let status = resp.status();
10837        let body = resp.text().await.map_err(|e| {
10838            InferenceError::InferenceFailed(format!("read ElevenLabs STT body: {e}"))
10839        })?;
10840        if !status.is_success() {
10841            return Err(InferenceError::InferenceFailed(format!(
10842                "ElevenLabs STT returned {status}: {body}"
10843            )));
10844        }
10845        let payload: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
10846            InferenceError::InferenceFailed(format!("parse ElevenLabs STT response: {e}"))
10847        })?;
10848        let text = payload
10849            .get("text")
10850            .and_then(|v| v.as_str())
10851            .map(str::to_string)
10852            .ok_or_else(|| {
10853                InferenceError::InferenceFailed("ElevenLabs STT response missing text".into())
10854            })?;
10855
10856        Ok(TranscribeResult {
10857            text,
10858            model_used: Some(schema.name.clone()),
10859            language: payload
10860                .get("language_code")
10861                .and_then(|v| v.as_str())
10862                .map(str::to_string),
10863            words: Vec::new(),
10864            routing_explanation: None,
10865        })
10866    }
10867
10868    async fn synthesize_elevenlabs(
10869        &self,
10870        schema: &ModelSchema,
10871        req: &SynthesizeRequest,
10872    ) -> Result<SynthesizeResult, InferenceError> {
10873        // ElevenLabs doesn't expose a Qwen3-TTS-style cloning or
10874        // voice-design surface on its `/v1/text-to-speech` endpoint;
10875        // honor the strict_capabilities contract here too.
10876        let requested = req.requested_advanced_controls();
10877        if !requested.is_empty() {
10878            if req.strict_capabilities {
10879                return Err(InferenceError::InferenceFailed(format!(
10880                    "ElevenLabs backend does not support Qwen3-TTS advanced controls \
10881                     {requested:?}; route to a Qwen3-TTS model or set strict_capabilities = false"
10882                )));
10883            }
10884            tracing::warn!(
10885                model = %schema.name,
10886                fields = ?requested,
10887                "Qwen3-TTS advanced controls ignored by ElevenLabs backend"
10888            );
10889        }
10890        let (endpoint, api_key) = elevenlabs_auth(
10891            schema,
10892            crate::tasks::synthesize::resolve_provider_credential_for_request,
10893        )?;
10894        let voice_id = req
10895            .voice
10896            .clone()
10897            .unwrap_or_else(|| "JBFqnCBsd6RMkjVDRZzb".to_string());
10898        let output_format = elevenlabs_output_format(&req.format);
10899        let url = format!(
10900            "{}/v1/text-to-speech/{}?output_format={}",
10901            endpoint.trim_end_matches('/'),
10902            voice_id,
10903            output_format
10904        );
10905
10906        let mut body = serde_json::json!({
10907            "text": req.text,
10908            "model_id": schema.name,
10909        });
10910        if let Some(language) = &req.language {
10911            body["language_code"] = serde_json::Value::String(language.clone());
10912        }
10913
10914        let resp = self
10915            .remote_backend
10916            .client
10917            .post(url)
10918            .header("xi-api-key", api_key)
10919            .header("Content-Type", "application/json")
10920            .json(&body)
10921            .send()
10922            .await
10923            .map_err(|e| {
10924                self.remote_backend
10925                    .request_error("ElevenLabs TTS request failed", &e)
10926            })?;
10927        let status = resp.status();
10928        let audio = resp.bytes().await.map_err(|e| {
10929            InferenceError::InferenceFailed(format!("read ElevenLabs TTS body: {e}"))
10930        })?;
10931        if !status.is_success() {
10932            let err_body = String::from_utf8_lossy(&audio);
10933            return Err(InferenceError::InferenceFailed(format!(
10934                "ElevenLabs TTS returned {status}: {err_body}"
10935            )));
10936        }
10937
10938        let final_path = requested_or_temp_output(req.output_path.as_deref(), &req.format)?;
10939        ensure_parent_dir(&final_path)?;
10940        tokio::fs::write(&final_path, &audio).await?;
10941
10942        Ok(SynthesizeResult {
10943            audio_path: final_path.display().to_string(),
10944            media_type: media_type_for_format(&req.format),
10945            model_used: Some(schema.name.clone()),
10946            voice_used: Some(voice_id),
10947            routing_explanation: None,
10948        })
10949    }
10950}
10951
10952#[derive(Default)]
10953struct ProviderAccumulator {
10954    configured: bool,
10955    local_models: usize,
10956    remote_models: usize,
10957    available_models: usize,
10958    capabilities: std::collections::HashSet<ModelCapability>,
10959}
10960
10961// ─── Python Speech Runtime (non-Apple-Silicon only) ──────────────────────────
10962// On Apple Silicon, speech uses native MLX backends (mlx_parakeet, mlx_kokoro).
10963
10964struct CommandOutput {
10965    stdout: String,
10966    stderr: String,
10967}
10968
10969#[derive(Debug, Clone)]
10970struct SpeechRuntime {
10971    root: PathBuf,
10972    python: PathBuf,
10973    stt_program: PathBuf,
10974    tts_program: PathBuf,
10975}
10976
10977impl SpeechRuntime {
10978    fn new(root: PathBuf) -> Self {
10979        let python = managed_venv::interpreter(&root);
10980        let stt_program = managed_venv::venv_program(&root, "mlx_audio.stt.generate");
10981        let tts_program = managed_venv::venv_program(&root, "mlx_audio.tts.generate");
10982        Self {
10983            root,
10984            python,
10985            stt_program,
10986            tts_program,
10987        }
10988    }
10989
10990    fn is_ready(&self) -> bool {
10991        // `interpreter_healthy` runs the interpreter rather than stat-ing it.
10992        // A venv whose Python was rotated away by a Homebrew upgrade still has
10993        // every console script sitting on disk, so a pure existence check
10994        // reports "ready" for a runtime where all of them die at their shebang.
10995        managed_venv::interpreter_healthy(&self.root)
10996            && self.stt_program.exists()
10997            && self.tts_program.exists()
10998    }
10999
11000    fn command_for(&self, subcommand: &str) -> Result<&Path, InferenceError> {
11001        match subcommand {
11002            "stt.generate" => Ok(&self.stt_program),
11003            "tts.generate" => Ok(&self.tts_program),
11004            _ => Err(InferenceError::InferenceFailed(format!(
11005                "unknown speech subcommand: {subcommand}"
11006            ))),
11007        }
11008    }
11009}
11010
11011async fn run_mlx_audio_command(
11012    runtime: &SpeechRuntime,
11013    subcommand: &str,
11014    args: &[String],
11015    detached_lease: resource_policy::DetachedLocalLease,
11016) -> Result<CommandOutput, InferenceError> {
11017    run_mlx_audio_command_with_env(runtime, subcommand, args, &[], detached_lease).await
11018}
11019
11020/// Run synchronous native model work with an owned machine-budget charge.
11021/// Tokio cannot cancel a started `spawn_blocking` closure, so the lease must
11022/// live inside the closure rather than in the awaiting request future.
11023async fn run_admitted_blocking<F, T>(
11024    detached_lease: resource_policy::DetachedLocalLease,
11025    operation: F,
11026) -> Result<T, tokio::task::JoinError>
11027where
11028    F: FnOnce() -> T + Send + 'static,
11029    T: Send + 'static,
11030{
11031    tokio::task::spawn_blocking(move || {
11032        let _detached_lease = detached_lease;
11033        operation()
11034    })
11035    .await
11036}
11037
11038struct DetachedSpeechProcess {
11039    child: Option<tokio::process::Child>,
11040    lease: Option<resource_policy::DetachedLocalLease>,
11041}
11042
11043impl Drop for DetachedSpeechProcess {
11044    fn drop(&mut self) {
11045        let Some(mut child) = self.child.take() else {
11046            return;
11047        };
11048        let lease = self.lease.take();
11049        let _ = child.start_kill();
11050        if tokio::runtime::Handle::try_current().is_ok() {
11051            tokio::spawn(async move {
11052                let _ = child.wait().await;
11053                drop(lease);
11054            });
11055        } else {
11056            // Without an executor we cannot confirm OS exit. Preserve both
11057            // ownership and charge rather than advertise unsafe headroom.
11058            std::mem::forget((child, lease));
11059        }
11060    }
11061}
11062
11063async fn run_mlx_audio_command_with_env(
11064    runtime: &SpeechRuntime,
11065    subcommand: &str,
11066    args: &[String],
11067    envs: &[(String, String)],
11068    detached_lease: resource_policy::DetachedLocalLease,
11069) -> Result<CommandOutput, InferenceError> {
11070    let program = runtime.command_for(subcommand)?;
11071    let mut command = Command::new(program);
11072    command.args(args);
11073    for (key, value) in envs {
11074        command.env(key, value);
11075    }
11076    command
11077        .stdout(std::process::Stdio::piped())
11078        .stderr(std::process::Stdio::piped())
11079        .kill_on_drop(true);
11080    let child = command
11081        .spawn()
11082        .map_err(|err| InferenceError::InferenceFailed(format!("{}: {err}", program.display())))?;
11083    let mut owned = DetachedSpeechProcess {
11084        child: Some(child),
11085        lease: Some(detached_lease),
11086    };
11087    let mut stdout = owned
11088        .child
11089        .as_mut()
11090        .expect("speech child owned")
11091        .stdout
11092        .take()
11093        .ok_or_else(|| {
11094            InferenceError::InferenceFailed(format!("{} stdout unavailable", program.display()))
11095        })?;
11096    let mut stderr = owned
11097        .child
11098        .as_mut()
11099        .expect("speech child owned")
11100        .stderr
11101        .take()
11102        .ok_or_else(|| {
11103            InferenceError::InferenceFailed(format!("{} stderr unavailable", program.display()))
11104        })?;
11105    let stdout_reader = tokio::spawn(async move {
11106        let mut bytes = Vec::new();
11107        stdout.read_to_end(&mut bytes).await.map(|_| bytes)
11108    });
11109    let stderr_reader = tokio::spawn(async move {
11110        let mut bytes = Vec::new();
11111        stderr.read_to_end(&mut bytes).await.map(|_| bytes)
11112    });
11113    let status = owned
11114        .child
11115        .as_mut()
11116        .expect("speech child owned")
11117        .wait()
11118        .await
11119        .map_err(|err| InferenceError::InferenceFailed(format!("{}: {err}", program.display())))?;
11120    owned.child.take();
11121    owned.lease.take();
11122    let stdout = stdout_reader
11123        .await
11124        .map_err(|err| InferenceError::InferenceFailed(format!("speech stdout join: {err}")))?
11125        .map_err(|err| InferenceError::InferenceFailed(format!("speech stdout: {err}")))?;
11126    let stderr = stderr_reader
11127        .await
11128        .map_err(|err| InferenceError::InferenceFailed(format!("speech stderr join: {err}")))?
11129        .map_err(|err| InferenceError::InferenceFailed(format!("speech stderr: {err}")))?;
11130
11131    if status.success() {
11132        Ok(CommandOutput {
11133            stdout: String::from_utf8_lossy(&stdout).to_string(),
11134            stderr: String::from_utf8_lossy(&stderr).to_string(),
11135        })
11136    } else {
11137        Err(InferenceError::InferenceFailed(format!(
11138            "{} exited with {}: {}",
11139            program.display(),
11140            status,
11141            String::from_utf8_lossy(&stderr)
11142        )))
11143    }
11144}
11145
11146async fn bootstrap_speech_runtime(runtime: &SpeechRuntime) -> Result<(), InferenceError> {
11147    let python = select_speech_python()?;
11148
11149    // A bare `uv venv` hard-fails against an existing directory, so this used to
11150    // abort on every machine that had already provisioned the runtime once —
11151    // including the case this bootstrap exists to handle, where the venv is
11152    // present but its interpreter was rotated away by a Homebrew upgrade.
11153    // `managed_venv` reuses a healthy venv and rebuilds a broken one.
11154    let outcome = managed_venv::ensure_venv(&runtime.root, &python)
11155        .await
11156        .map_err(|err| InferenceError::InferenceFailed(err.to_string()))?;
11157    if outcome == managed_venv::VenvOutcome::Recreated {
11158        tracing::warn!(
11159            root = %runtime.root.display(),
11160            "managed speech runtime had an unusable interpreter; rebuilt it \
11161             (installed packages were discarded and are being reinstalled)"
11162        );
11163    }
11164
11165    run_command(
11166        "uv",
11167        &[
11168            "pip".to_string(),
11169            "install".to_string(),
11170            "--python".to_string(),
11171            runtime.python.display().to_string(),
11172            speech_runtime_mlx_audio_spec(),
11173            "misaki[en]".to_string(),
11174            speech_runtime_spacy_model_spec(),
11175        ],
11176    )
11177    .await?;
11178
11179    Ok(())
11180}
11181
11182async fn run_command(program: &str, args: &[String]) -> Result<(), InferenceError> {
11183    let output = Command::new(program)
11184        .args(args)
11185        .output()
11186        .await
11187        .map_err(|err| InferenceError::InferenceFailed(format!("{program}: {err}")))?;
11188
11189    if output.status.success() {
11190        Ok(())
11191    } else {
11192        Err(InferenceError::InferenceFailed(format!(
11193            "{} exited with {}: {}",
11194            program,
11195            output.status,
11196            String::from_utf8_lossy(&output.stderr)
11197        )))
11198    }
11199}
11200
11201fn select_speech_python() -> Result<String, InferenceError> {
11202    if let Ok(path) = std::env::var("CAR_SPEECH_PYTHON") {
11203        if !path.trim().is_empty() {
11204            return Ok(path);
11205        }
11206    }
11207
11208    for candidate in ["python3.13", "python3.12", "python3.11"] {
11209        if command_in_path(candidate) {
11210            return Ok(candidate.to_string());
11211        }
11212    }
11213
11214    // Nothing supported on PATH — hand `uv` a bare version instead of a binary
11215    // name and let it provision one. `uv venv --python 3.12` downloads and
11216    // manages the interpreter itself, and uv is already a hard prerequisite of
11217    // this bootstrap, so this adds no new dependency.
11218    //
11219    // Without this, a machine whose only Python is newer than the supported
11220    // range (e.g. 3.14, which mlx-audio does not yet build against) could not
11221    // install the speech runtime at all, and local voice was simply unavailable
11222    // — even though the fix was one flag away (Parslee-ai/car#640).
11223    Ok(SPEECH_RUNTIME_FALLBACK_PYTHON.to_string())
11224}
11225
11226/// Python version `uv` provisions when no supported interpreter is on PATH.
11227///
11228/// Pinned to a version `mlx-audio` and `misaki` actually support — deliberately
11229/// not "whatever is newest", since the newest release is routinely ahead of what
11230/// the speech stack builds against, which is the situation this exists for.
11231const SPEECH_RUNTIME_FALLBACK_PYTHON: &str = "3.12";
11232
11233fn detect_speech_python() -> Option<String> {
11234    if let Ok(path) = std::env::var("CAR_SPEECH_PYTHON") {
11235        if !path.trim().is_empty() {
11236            return Some(path);
11237        }
11238    }
11239
11240    ["python3.13", "python3.12", "python3.11"]
11241        .into_iter()
11242        .find(|candidate| command_in_path(candidate))
11243        .map(str::to_string)
11244}
11245
11246fn speech_runtime_root_from_models_dir(_models_dir: &Path) -> PathBuf {
11247    if let Ok(path) = std::env::var("CAR_SPEECH_RUNTIME_DIR") {
11248        if !path.trim().is_empty() {
11249            return PathBuf::from(path);
11250        }
11251    }
11252
11253    std::env::var_os("HOME")
11254        .or_else(|| std::env::var_os("USERPROFILE"))
11255        .map(PathBuf::from)
11256        .unwrap_or_else(|| PathBuf::from("."))
11257        .join(".car")
11258        .join("speech-runtime")
11259}
11260
11261fn command_in_path(name: &str) -> bool {
11262    std::env::var_os("PATH")
11263        .map(|paths| {
11264            std::env::split_paths(&paths).any(|dir| {
11265                let path = dir.join(name);
11266                path.exists() && path.is_file()
11267            })
11268        })
11269        .unwrap_or(false)
11270}
11271
11272fn speech_model_cached(schema: &ModelSchema) -> bool {
11273    match &schema.source {
11274        ModelSource::Mlx { hf_repo, .. } => huggingface_repo_has_snapshot(hf_repo),
11275        ModelSource::WhisperCpp { model } => car_whisper::model_cached(model),
11276        // OS-provided (WinRT) — nothing to cache; "cached" tracks availability.
11277        ModelSource::WindowsSpeech {} => cfg!(target_os = "windows"),
11278        // A remote credential is configuration, not a cached model artifact.
11279        // Credential truth is established only at explicit request time.
11280        ModelSource::Proprietary { .. } => false,
11281        _ => false,
11282    }
11283}
11284
11285fn model_source_configured(schema: &ModelSchema) -> bool {
11286    match &schema.source {
11287        ModelSource::RemoteApi {
11288            protocol: ApiProtocol::OpenRouter,
11289            ..
11290        } => crate::openrouter::credential_source().is_some(),
11291        ModelSource::RemoteApi {
11292            api_key_env,
11293            api_key_envs,
11294            ..
11295        } => {
11296            std::env::var(api_key_env).is_ok_and(|value| !value.trim().is_empty())
11297                || api_key_envs.iter().any(|env_var| {
11298                    std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11299                })
11300        }
11301        ModelSource::Proprietary { auth, .. } => match auth {
11302            ProprietaryAuth::ApiKeyEnv { env_var } => {
11303                std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11304            }
11305            ProprietaryAuth::BearerTokenEnv { env_var } => {
11306                std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
11307            }
11308            ProprietaryAuth::OAuth2Pkce { .. } => matches!(
11309                car_auth::credential_authority_hint().state,
11310                car_auth::CredentialAuthorityState::Configured
11311            ),
11312        },
11313        ModelSource::VllmMlx { .. } => {
11314            std::env::var("VLLM_MLX_ENDPOINT").is_ok() || schema.available
11315        }
11316        ModelSource::Ollama { .. } => schema.available,
11317        ModelSource::CodexCli { .. } => crate::backend::codex_cli::is_available(),
11318        ModelSource::Mlx { .. }
11319        | ModelSource::ManagedVllmMlx { .. }
11320        | ModelSource::Local { .. }
11321        | ModelSource::WhisperCpp { .. } => true,
11322        // OS-provided; "configured" tracks platform availability (Windows-only).
11323        ModelSource::WindowsSpeech {} => schema.available,
11324        ModelSource::AppleFoundationModels { .. } => schema.available,
11325        // Delegated models route through a host-registered runner —
11326        // the runner's own auth / readiness is opaque here. Treat
11327        // them as configured; missing-runner errors surface at
11328        // dispatch time with a clear message.
11329        ModelSource::Delegated { .. } => true,
11330    }
11331}
11332
11333fn all_model_capabilities() -> [ModelCapability; 13] {
11334    [
11335        ModelCapability::Generate,
11336        ModelCapability::Embed,
11337        ModelCapability::Classify,
11338        ModelCapability::Code,
11339        ModelCapability::Reasoning,
11340        ModelCapability::Summarize,
11341        ModelCapability::ToolUse,
11342        ModelCapability::MultiToolCall,
11343        ModelCapability::Vision,
11344        ModelCapability::SpeechToText,
11345        ModelCapability::TextToSpeech,
11346        ModelCapability::ImageGeneration,
11347        ModelCapability::VideoGeneration,
11348    ]
11349}
11350
11351fn sort_capabilities(mut capabilities: Vec<ModelCapability>) -> Vec<ModelCapability> {
11352    capabilities.sort_by_key(|capability| {
11353        all_model_capabilities()
11354            .iter()
11355            .position(|candidate| candidate == capability)
11356            .unwrap_or(usize::MAX)
11357    });
11358    capabilities
11359}
11360
11361fn speech_model_source_label(schema: &ModelSchema) -> String {
11362    match &schema.source {
11363        ModelSource::Mlx { hf_repo, .. } => format!("mlx:{hf_repo}"),
11364        ModelSource::ManagedVllmMlx { hf_repo, .. } => {
11365            format!("managed-vllm-mlx:{hf_repo}")
11366        }
11367        ModelSource::WhisperCpp { model } => format!("whisper:{model}"),
11368        ModelSource::WindowsSpeech {} => "windows-speech".to_string(),
11369        ModelSource::Proprietary {
11370            provider, endpoint, ..
11371        } => format!("proprietary:{provider}:{endpoint}"),
11372        ModelSource::RemoteApi { endpoint, .. } => format!("remote:{endpoint}"),
11373        ModelSource::CodexCli { model } => format!("codex-cli:{model}"),
11374        ModelSource::Local { hf_repo, .. } => format!("local:{hf_repo}"),
11375        ModelSource::VllmMlx {
11376            endpoint,
11377            model_name,
11378        } => format!("vllm-mlx:{endpoint}:{model_name}"),
11379        ModelSource::Ollama { model_tag, host } => format!("ollama:{host}:{model_tag}"),
11380        ModelSource::AppleFoundationModels { use_case } => {
11381            format!(
11382                "apple-foundation:{}",
11383                use_case.as_deref().unwrap_or("default")
11384            )
11385        }
11386        ModelSource::Delegated { hint } => {
11387            format!("delegated:{}", hint.as_deref().unwrap_or("(none)"))
11388        }
11389    }
11390}
11391
11392/// Build the Qwen3-Reranker chat-template prompt for a single
11393/// `(query, document)` candidate.
11394///
11395/// The format matches upstream `reranker_quick_start.py`: a system
11396/// message pinning the answer space to yes/no, a user turn with
11397/// `<Instruct>/<Query>/<Document>`, and an assistant prefix with a
11398/// closed empty `<think>` block to force non-thinking classification.
11399fn rerank_prompt(instruction: &str, query: &str, document: &str) -> String {
11400    const SYSTEM: &str = "Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".";
11401    format!(
11402        "<|im_start|>system\n{SYSTEM}<|im_end|>\n\
11403         <|im_start|>user\n<Instruct>: {instruction}\n<Query>: {query}\n<Document>: {document}<|im_end|>\n\
11404         <|im_start|>assistant\n<think>\n\n</think>\n\n"
11405    )
11406}
11407
11408/// Interpret the first useful token from a Qwen3-Reranker greedy
11409/// decode as a relevance score. Scans up to the first few tokens so
11410/// a leading space, BOS artifact, or stray newline doesn't poison the
11411/// result. Returns 1.0 for "yes", 0.0 for "no", 0.5 on unexpected
11412/// output (with a warn so the mismatch is visible).
11413/// Map an exhausted-fallback-chain error to actionable recovery
11414/// guidance, or `None` when the underlying error isn't a
11415/// missing-backend/credential case.
11416///
11417/// Matches *specific* CAR error phrases, not broad words like "auth"
11418/// or "token", so a transient failure on an otherwise-configured model
11419/// — an HTTP 401/403/429 (`API returned 4xx`), a connection refused, a
11420/// timeout — passes through unchanged and isn't buried under
11421/// first-run setup advice. The four matched phrases are the ones the
11422/// engine actually emits when there is genuinely no runnable backend:
11423/// proprietary provider with no credential, a routed model that isn't
11424/// installed, an empty registry, or a delegated model with no runner.
11425/// See Parslee-ai/car#231 §7.1.
11426/// Recover the HTTP status the remote backend formats into
11427/// "API returned <status>: <body>" error strings, so transient
11428/// classification keys on the REAL status instead of substring-sniffing
11429/// a body that may quote another status or the word "timeout"
11430/// (I4 review; typed error plumbing is the follow-up).
11431fn parse_api_returned_status(err: &str) -> Option<u16> {
11432    let lower = err.to_ascii_lowercase();
11433    let idx = lower.find("api returned ")?;
11434    let digits: String = lower[idx + "api returned ".len()..]
11435        .chars()
11436        .take_while(|c| c.is_ascii_digit())
11437        .take(3)
11438        .collect();
11439    digits.parse().ok()
11440}
11441
11442fn no_backend_recovery_hint(underlying: &str) -> Option<String> {
11443    let is_no_backend = underlying.contains("no credential")
11444        || underlying.contains("model not found")
11445        || underlying.contains("no models available")
11446        || underlying.contains("no inference runner");
11447    if !is_no_backend {
11448        return None;
11449    }
11450    Some(format!(
11451        "no inference backend is available. To install a local, tool-capable \
11452         model (no account required, works on Windows/Linux/macOS), run:\n    \
11453         car models pull qwen/qwen3-4b:q4_k_m\n\
11454         To use Parslee's hosted models instead, run:\n    \
11455         car auth login\n\
11456         (underlying error: {underlying})"
11457    ))
11458}
11459
11460/// Whether an error message means **the credential was rejected** — the remedy
11461/// is for a human to sign in again (`car auth login`), not for the machinery to
11462/// retry.
11463///
11464/// This is the single definition of that question in the workspace.
11465/// [`auth_expired_recovery_hint`] consumes it to decide whether to print re-auth
11466/// guidance, and `car_server_core::coder::native_loop::is_auth_failure` consumes
11467/// it so the coder loop pauses for sign-in instead of burning inference strikes
11468/// (three of which throw away the worktree) on a credential that cannot possibly
11469/// succeed on retry — Parslee-ai/car#888, where the real expired-token error
11470/// (`Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required`)
11471/// matched none of that loop's hand-rolled substrings.
11472///
11473/// It deliberately does **not** match transient failures — 5xx, timeouts, reset
11474/// connections. Those are worth retrying and must keep flowing down the normal
11475/// failure path; classifying one as "sign in again" would tell an operator to
11476/// fix something that is not broken.
11477pub fn is_auth_rejection_message(underlying: &str) -> bool {
11478    let l = underlying.to_ascii_lowercase();
11479    l.contains("org lookup failed")
11480        || l.contains("authentication required")
11481        || (l.contains("401") && l.contains("unauthorized"))
11482        || (l.contains("403") && l.contains("forbidden"))
11483        || l.contains("invalid_grant")
11484        || l.contains("token expired")
11485}
11486
11487/// Whether a rendered inference failure needs credential repair rather than an
11488/// infrastructure retry.
11489///
11490/// Route summaries and consumers such as the coder loop share
11491/// `AUTH_FAILURE_MESSAGE_MARKERS` instead of maintaining independent phrase
11492/// lists that can disagree when a summary is added or reworded.
11493pub fn is_auth_failure_message(message: &str) -> bool {
11494    let lower = message.to_ascii_lowercase();
11495    AUTH_FAILURE_MESSAGE_MARKERS
11496        .iter()
11497        .any(|marker| lower.contains(marker))
11498        || is_auth_rejection_message(message)
11499}
11500
11501/// Record `candidate` as the dead lane if `error` is a credential rejection and
11502/// no dead lane has been recorded yet.
11503///
11504/// FIRST-wins, not last: the chain is walked in preference order, so the first
11505/// rejected lane is the one the operator actually configured and the one worth
11506/// naming. Non-auth failures (5xx, timeouts) leave the slot untouched — they are
11507/// not a sign-in problem. Extracted from the fallback loop so the rule is
11508/// directly testable (Parslee-ai/car#888).
11509fn record_auth_dead_lane(slot: &mut Option<String>, candidate: &str, error: &str) {
11510    if slot.is_none() && is_auth_rejection_message(error) {
11511        *slot = Some(candidate.to_string());
11512    }
11513}
11514
11515/// Whether the candidate that served was the on-device model appended behind
11516/// an otherwise remote-only chain. Comparing the attempted ids (rather than
11517/// display names) keeps aliases from turning ordinary local routing into a
11518/// false fallback marker.
11519fn is_local_last_resort(appended_id: Option<&str>, candidate_id: &str) -> bool {
11520    appended_id == Some(candidate_id)
11521}
11522
11523/// Mark and announce the moment the appended model actually serves. The INFO
11524/// at append time reports only that fallback was available; this WARN reports
11525/// the materially different fact that the turn degraded onto it.
11526fn report_local_last_resort_served(
11527    appended_id: Option<&str>,
11528    candidate_id: &str,
11529    resolved_id: &str,
11530) -> bool {
11531    let served = is_local_last_resort(appended_id, candidate_id);
11532    if served {
11533        tracing::warn!(
11534            local_model = %resolved_id,
11535            "on-device last-resort model served inference turn"
11536        );
11537    }
11538    served
11539}
11540
11541/// When the whole fallback chain was exhausted by an AUTH rejection — an
11542/// expired or revoked Parslee credential (401/403, "org lookup failed",
11543/// "Authentication required", "invalid_grant") — return actionable re-auth
11544/// guidance instead of a raw HTTP status. Distinct from
11545/// [`no_backend_recovery_hint`], which covers "no credential at all": this is
11546/// the "you WERE signed in but the session lapsed and the refresh didn't
11547/// recover" case, which otherwise surfaced verbatim as `HTTP 401 Unauthorized`.
11548/// Only reached on an exhausted chain, so a single transient 401 on an
11549/// otherwise-healthy alternative never lands here.
11550fn auth_expired_recovery_hint(underlying: &str) -> Option<String> {
11551    if !is_auth_rejection_message(underlying) {
11552        return None;
11553    }
11554    Some(format!(
11555        "your Parslee session has expired or was rejected — re-authenticate:\n    \
11556         car auth login\n\
11557         Or install a tool-capable on-device model so CAR can answer (including \
11558         tool use) without an account:\n    \
11559         car models pull qwen/qwen3-4b:q4_k_m\n\
11560         (underlying error: {underlying})"
11561    ))
11562}
11563
11564fn score_from_rerank_output(text: &str, model_name: &str) -> f32 {
11565    // Replace every non-alphanumeric byte with a space, lowercase,
11566    // and scan the first few whitespace-separated tokens for
11567    // "yes"/"no". This strips chat-template tags (`<|im_end|>`),
11568    // punctuation, and underscores cleanly without special-casing.
11569    let normalized: String = text
11570        .to_ascii_lowercase()
11571        .chars()
11572        .map(|c| if c.is_ascii_alphanumeric() { c } else { ' ' })
11573        .collect();
11574    for tok in normalized.split_ascii_whitespace().take(5) {
11575        match tok {
11576            "yes" => return 1.0,
11577            "no" => return 0.0,
11578            _ => continue,
11579        }
11580    }
11581    tracing::warn!(
11582        model = %model_name,
11583        output = %text,
11584        "rerank: first tokens contain neither `yes` nor `no`; returning neutral 0.5"
11585    );
11586    0.5
11587}
11588
11589fn default_speech_voice(schema: &ModelSchema) -> Option<String> {
11590    if schema.provider == "elevenlabs" {
11591        Some("JBFqnCBsd6RMkjVDRZzb".to_string())
11592    } else if schema.name == "Kokoro-82M-6bit" || schema.name == "Kokoro-82M-bf16" {
11593        Some("af_heart".to_string())
11594    } else if schema.name == "Qwen3-TTS-12Hz-1.7B-Base-5bit" {
11595        Some("Chelsie".to_string())
11596    } else {
11597        None
11598    }
11599}
11600
11601#[allow(dead_code)] // conditionally compiled — used only on MLX-backend (macOS) snapshot-resolution paths
11602fn huggingface_repo_has_snapshot(repo_id: &str) -> bool {
11603    find_latest_huggingface_snapshot(repo_id).is_some()
11604}
11605
11606fn huggingface_repo_dir(repo_id: &str) -> PathBuf {
11607    let cache_root = std::env::var("HF_HOME")
11608        .map(PathBuf::from)
11609        .unwrap_or_else(|_| {
11610            std::env::var_os("HOME")
11611                .or_else(|| std::env::var_os("USERPROFILE"))
11612                .map(PathBuf::from)
11613                .unwrap_or_else(|| PathBuf::from("."))
11614                .join(".cache")
11615                .join("huggingface")
11616        })
11617        .join("hub");
11618    cache_root.join(format!("models--{}", repo_id.replace('/', "--")))
11619}
11620
11621fn find_latest_huggingface_snapshot(repo_id: &str) -> Option<PathBuf> {
11622    let snapshots = huggingface_repo_dir(repo_id).join("snapshots");
11623    std::fs::read_dir(snapshots)
11624        .ok()?
11625        .filter_map(Result::ok)
11626        .map(|entry| entry.path())
11627        .find(|path| path.is_dir() && snapshot_looks_ready(path))
11628}
11629
11630fn snapshot_looks_ready(path: &Path) -> bool {
11631    if path.join("config.json").exists() || path.join("model_index.json").exists() {
11632        return true;
11633    }
11634    snapshot_contains_ext(path, "safetensors")
11635}
11636
11637fn snapshot_contains_ext(root: &Path, ext: &str) -> bool {
11638    let Ok(entries) = std::fs::read_dir(root) else {
11639        return false;
11640    };
11641    entries.filter_map(Result::ok).any(|entry| {
11642        let path = entry.path();
11643        if path.is_dir() {
11644            snapshot_contains_ext(&path, ext)
11645        } else {
11646            let ext_matches = path
11647                .extension()
11648                .and_then(|value| value.to_str())
11649                .map(|value| value.eq_ignore_ascii_case(ext))
11650                .unwrap_or(false);
11651            // A matching extension only counts when the file is actually usable
11652            // — a dangling symlink into a pruned blob or a zero-length partial
11653            // must not make a snapshot look ready.
11654            ext_matches && crate::download::cache_file_usable(&path)
11655        }
11656    })
11657}
11658
11659#[allow(dead_code)] // conditionally compiled — used only on MLX-backend (macOS) media-output paths
11660fn count_files_recursive(root: &Path) -> usize {
11661    let Ok(entries) = std::fs::read_dir(root) else {
11662        return 0;
11663    };
11664    entries
11665        .filter_map(Result::ok)
11666        .map(|entry| entry.path())
11667        .map(|path| {
11668            if path.is_dir() {
11669                count_files_recursive(&path)
11670            } else if path.is_file() {
11671                1
11672            } else {
11673                0
11674            }
11675        })
11676        .sum()
11677}
11678
11679async fn download_hf_repo_snapshot(repo_id: &str) -> Result<(PathBuf, usize), InferenceError> {
11680    let api = hf_hub::api::tokio::ApiBuilder::from_env()
11681        .with_progress(false)
11682        .build()
11683        .map_err(|e| InferenceError::DownloadFailed(format!("init hf api: {e}")))?;
11684    let repo = api.model(repo_id.to_string());
11685    let info = repo
11686        .info()
11687        .await
11688        .map_err(|e| InferenceError::DownloadFailed(format!("{repo_id}: {e}")))?;
11689
11690    let snapshot_path = huggingface_repo_dir(repo_id)
11691        .join("snapshots")
11692        .join(&info.sha);
11693    let mut downloaded = 0usize;
11694    for sibling in &info.siblings {
11695        let local_path = snapshot_path.join(&sibling.rfilename);
11696        // Presence is not integrity. The shared HF cache can hold a dangling
11697        // symlink (blob pruned by another tool) or a zero-length partial write
11698        // (interrupted/out-of-disk download). Skip the re-download only when the
11699        // cached file is actually usable; otherwise fall through so hf-hub
11700        // re-fetches metadata and rewrites the blob. (Cheap check only — a full
11701        // content hash per already-present file would re-hash the whole model
11702        // on every no-op pull; deep verification lives in the self-heal path.)
11703        if crate::download::cache_file_usable(&local_path) {
11704            downloaded += 1;
11705            continue;
11706        }
11707        // Clear a stale/dangling pointer first: hf-hub's symlink recreation
11708        // returns `AlreadyExists` if the old pointer file is still on disk and
11709        // the new etag differs, surfacing as a confusing error instead of a
11710        // repair. Removing it lets hf-hub always recreate the snapshot link.
11711        let _ = std::fs::remove_file(&local_path);
11712        repo.download(&sibling.rfilename).await.map_err(|e| {
11713            InferenceError::DownloadFailed(format!("{repo_id}/{}: {e}", sibling.rfilename))
11714        })?;
11715        downloaded += 1;
11716    }
11717
11718    Ok((snapshot_path, downloaded))
11719}
11720
11721fn temp_work_dir(prefix: &str) -> Result<PathBuf, InferenceError> {
11722    let unique = SystemTime::now()
11723        .duration_since(UNIX_EPOCH)
11724        .map_err(|e| InferenceError::InferenceFailed(format!("clock error: {e}")))?
11725        .as_nanos();
11726    let dir = std::env::temp_dir().join(format!("car-inference-{prefix}-{unique}"));
11727    std::fs::create_dir_all(&dir)?;
11728    Ok(dir)
11729}
11730
11731fn ensure_parent_dir(path: &Path) -> Result<(), InferenceError> {
11732    if let Some(parent) = path.parent() {
11733        std::fs::create_dir_all(parent)?;
11734    }
11735    Ok(())
11736}
11737
11738fn requested_or_temp_output(
11739    output_path: Option<&str>,
11740    format: &str,
11741) -> Result<PathBuf, InferenceError> {
11742    if let Some(path) = output_path {
11743        return Ok(PathBuf::from(path));
11744    }
11745    let dir = temp_work_dir("audio-out")?;
11746    Ok(dir.join(format!("speech.{format}")))
11747}
11748
11749#[allow(dead_code)] // conditionally compiled — used only on MLX-backend (macOS) media-output paths
11750fn requested_or_temp_media_output(
11751    output_path: Option<&str>,
11752    format: &str,
11753    stem: &str,
11754) -> Result<PathBuf, InferenceError> {
11755    if let Some(path) = output_path {
11756        return Ok(PathBuf::from(path));
11757    }
11758    let dir = temp_work_dir(&format!("{stem}-out"))?;
11759    Ok(dir.join(format!("{stem}.{format}")))
11760}
11761
11762fn materialize_audio_output(
11763    produced: &Path,
11764    requested: Option<&str>,
11765    format: &str,
11766) -> Result<PathBuf, InferenceError> {
11767    if let Some(path) = requested {
11768        let dest = PathBuf::from(path);
11769        ensure_parent_dir(&dest)?;
11770        std::fs::copy(produced, &dest)?;
11771        Ok(dest)
11772    } else {
11773        let dest = requested_or_temp_output(None, format)?;
11774        ensure_parent_dir(&dest)?;
11775        std::fs::copy(produced, &dest)?;
11776        Ok(dest)
11777    }
11778}
11779
11780/// Synthesize `text` to WAV bytes via WinRT `Windows.Media.SpeechSynthesis`.
11781/// Mirrors car-voice's `windows_speech_tts` (the live path), but returns bytes
11782/// for the catalog synthesize path (which writes them to a file). The two can't
11783/// share code without a car-inference→car-voice cycle, and it's a small
11784/// Windows-only helper, so it's duplicated deliberately. Windows-only.
11785#[cfg(target_os = "windows")]
11786fn winrt_synthesize_wav(text: &str, voice: &str, rate: f64) -> Result<Vec<u8>, InferenceError> {
11787    use windows::core::HSTRING;
11788    use windows::Media::SpeechSynthesis::SpeechSynthesizer;
11789    use windows::Storage::Streams::DataReader;
11790
11791    let err = |m: String| InferenceError::InferenceFailed(m);
11792    let synth =
11793        SpeechSynthesizer::new().map_err(|e| err(format!("SpeechSynthesizer::new: {e}")))?;
11794    if let Ok(opts) = synth.Options() {
11795        let _ = opts.SetSpeakingRate(rate.clamp(0.5, 6.0));
11796    }
11797    if !voice.is_empty() {
11798        if let Ok(all) = SpeechSynthesizer::AllVoices() {
11799            let want = voice.to_lowercase();
11800            let count = all.Size().unwrap_or(0);
11801            for i in 0..count {
11802                if let Ok(info) = all.GetAt(i) {
11803                    if let Ok(name) = info.DisplayName() {
11804                        if name.to_string_lossy().to_lowercase().contains(&want) {
11805                            let _ = synth.SetVoice(&info);
11806                            break;
11807                        }
11808                    }
11809                }
11810            }
11811        }
11812    }
11813    let stream = synth
11814        .SynthesizeTextToStreamAsync(&HSTRING::from(text))
11815        .map_err(|e| err(format!("SynthesizeTextToStreamAsync: {e}")))?
11816        .get()
11817        .map_err(|e| err(format!("synthesize await: {e}")))?;
11818    let size = stream
11819        .Size()
11820        .map_err(|e| err(format!("stream size: {e}")))?;
11821    let input = stream
11822        .GetInputStreamAt(0)
11823        .map_err(|e| err(format!("input stream: {e}")))?;
11824    let reader =
11825        DataReader::CreateDataReader(&input).map_err(|e| err(format!("data reader: {e}")))?;
11826    reader
11827        .LoadAsync(size as u32)
11828        .map_err(|e| err(format!("load async: {e}")))?
11829        .get()
11830        .map_err(|e| err(format!("load await: {e}")))?;
11831    let mut buf = vec![0u8; size as usize];
11832    reader
11833        .ReadBytes(&mut buf)
11834        .map_err(|e| err(format!("read bytes: {e}")))?;
11835    Ok(buf)
11836}
11837
11838#[allow(dead_code)] // conditionally compiled — used only on backend-conditional transcription paths
11839fn read_transcription_result(output_prefix: &Path) -> Result<Option<String>, InferenceError> {
11840    let candidates = [
11841        output_prefix.with_extension("json"),
11842        output_prefix.to_path_buf(),
11843    ];
11844
11845    for path in candidates {
11846        if path.exists() {
11847            let contents = std::fs::read_to_string(path)?;
11848            if let Some(text) = extract_text_from_payload(&contents) {
11849                return Ok(Some(text));
11850            }
11851        }
11852    }
11853
11854    Ok(None)
11855}
11856
11857#[allow(dead_code)] // conditionally compiled — used only on backend-conditional transcription paths
11858fn extract_text_from_payload(payload: &str) -> Option<String> {
11859    let value: serde_json::Value = serde_json::from_str(payload).ok()?;
11860    if let Some(text) = value.get("text").and_then(|v| v.as_str()) {
11861        return Some(text.to_string());
11862    }
11863    if let Some(transcripts) = value.get("transcripts").and_then(|v| v.as_array()) {
11864        let joined = transcripts
11865            .iter()
11866            .filter_map(|item| item.get("text").and_then(|v| v.as_str()))
11867            .collect::<Vec<_>>()
11868            .join("\n");
11869        if !joined.is_empty() {
11870            return Some(joined);
11871        }
11872    }
11873    if let Some(items) = value.as_array() {
11874        let joined = items
11875            .iter()
11876            .filter_map(|item| {
11877                item.get("text")
11878                    .or_else(|| item.get("Content"))
11879                    .and_then(|v| v.as_str())
11880            })
11881            .collect::<Vec<_>>()
11882            .join(" ");
11883        if !joined.is_empty() {
11884            return Some(joined);
11885        }
11886    }
11887    None
11888}
11889
11890#[allow(dead_code)] // conditionally compiled — used only on backend-conditional speech-output paths
11891fn find_audio_file(output_dir: &Path) -> Result<Option<PathBuf>, InferenceError> {
11892    let mut audio_files = Vec::new();
11893    collect_audio_files(output_dir, &mut audio_files)?;
11894    audio_files.sort();
11895    Ok(audio_files.into_iter().next())
11896}
11897
11898#[allow(dead_code)] // conditionally compiled — used only on backend-conditional speech-output paths
11899fn collect_audio_files(dir: &Path, audio_files: &mut Vec<PathBuf>) -> Result<(), InferenceError> {
11900    for entry in std::fs::read_dir(dir)? {
11901        let path = entry?.path();
11902        if path.is_dir() {
11903            collect_audio_files(&path, audio_files)?;
11904        } else if matches!(
11905            path.extension().and_then(|ext| ext.to_str()),
11906            Some("wav" | "mp3" | "flac" | "pcm" | "m4a")
11907        ) {
11908            audio_files.push(path);
11909        }
11910    }
11911    Ok(())
11912}
11913
11914fn media_type_for_format(format: &str) -> String {
11915    match format.to_ascii_lowercase().as_str() {
11916        "mp3" => "audio/mpeg".to_string(),
11917        "flac" => "audio/flac".to_string(),
11918        "pcm" => "audio/L16".to_string(),
11919        "m4a" => "audio/mp4".to_string(),
11920        _ => "audio/wav".to_string(),
11921    }
11922}
11923
11924fn kokoro_lang_code(language: Option<&str>) -> &'static str {
11925    match language.unwrap_or("en").to_ascii_lowercase().as_str() {
11926        "en-gb" | "british" | "british english" => "b",
11927        "ja" | "japanese" => "j",
11928        "zh" | "zh-cn" | "mandarin" | "chinese" => "z",
11929        "es" | "spanish" => "e",
11930        "fr" | "french" => "f",
11931        _ => "a",
11932    }
11933}
11934
11935#[allow(dead_code)] // conditionally compiled — used only on backend-conditional transcription paths
11936fn normalize_lang_code(language: &str) -> String {
11937    match language.to_ascii_lowercase().as_str() {
11938        "english" | "en-us" | "en_us" => "en".to_string(),
11939        "spanish" => "es".to_string(),
11940        "french" => "fr".to_string(),
11941        "japanese" => "ja".to_string(),
11942        "chinese" | "mandarin" => "zh".to_string(),
11943        other => match other {
11944            "en" | "es" | "fr" | "ja" | "zh" => other.to_string(),
11945            _ => "en".to_string(),
11946        },
11947    }
11948}
11949
11950fn elevenlabs_auth(
11951    schema: &ModelSchema,
11952    resolve_credential: fn(&str) -> Option<String>,
11953) -> Result<(String, String), InferenceError> {
11954    match &schema.source {
11955        ModelSource::Proprietary {
11956            endpoint,
11957            auth: schema::ProprietaryAuth::ApiKeyEnv { env_var },
11958            ..
11959        } => {
11960            let key = resolve_credential(env_var).ok_or_else(|| {
11961                InferenceError::InferenceFailed(format!(
11962                    "missing API key {env_var}; set the environment variable or \
11963                     store it with `car secrets put {env_var}`"
11964                ))
11965            })?;
11966            Ok((endpoint.clone(), key))
11967        }
11968        _ => Err(InferenceError::InferenceFailed(format!(
11969            "model {} is not an ElevenLabs proprietary model",
11970            schema.id
11971        ))),
11972    }
11973}
11974
11975fn elevenlabs_output_format(format: &str) -> &'static str {
11976    match format.to_ascii_lowercase().as_str() {
11977        "mp3" => "mp3_44100_128",
11978        "pcm" => "pcm_16000",
11979        _ => "wav_44100",
11980    }
11981}
11982
11983/// Benchmark-prior files to merge, in order: `<state models dir>/`, its parent
11984/// (the state root), and an explicit `CAR_BENCHMARK_PRIORS_PATH`. Takes the
11985/// *state* models dir ([`InferenceConfig::state_models_dir`]), not the shared
11986/// weights dir, so a relocated daemon reads its own priors.
11987fn benchmark_priors_paths(state_models_dir: &Path) -> Vec<PathBuf> {
11988    let mut paths = Vec::new();
11989
11990    let direct = state_models_dir.join("benchmark_priors.json");
11991    if !paths.contains(&direct) {
11992        paths.push(direct);
11993    }
11994
11995    if let Some(parent) = state_models_dir.parent() {
11996        let parent_path = parent.join("benchmark_priors.json");
11997        if !paths.contains(&parent_path) {
11998            paths.push(parent_path);
11999        }
12000    }
12001
12002    if let Some(path) = std::env::var_os("CAR_BENCHMARK_PRIORS_PATH") {
12003        let path = PathBuf::from(path);
12004        if !paths.contains(&path) {
12005            paths.push(path);
12006        }
12007    }
12008
12009    paths
12010}
12011
12012fn load_benchmark_prior_health(
12013    state_models_dir: &Path,
12014    schemas: &[ModelSchema],
12015) -> Vec<ModelBenchmarkPriorHealth> {
12016    let mut priors = std::collections::BTreeMap::new();
12017    for path in benchmark_priors_paths(state_models_dir) {
12018        let Ok(loaded) = routing_ext::load_benchmark_priors(&path) else {
12019            continue;
12020        };
12021        for (model_id, prior) in loaded {
12022            let model_name = schemas
12023                .iter()
12024                .find(|schema| schema.id == model_id)
12025                .map(|schema| schema.name.clone());
12026            priors.insert(
12027                model_id.clone(),
12028                ModelBenchmarkPriorHealth {
12029                    model_id,
12030                    model_name,
12031                    overall_score: prior.overall_score,
12032                    overall_latency_ms: prior.overall_latency_ms,
12033                    task_scores: prior.task_scores,
12034                    task_latency_ms: prior.task_latency_ms,
12035                    source_path: path.clone(),
12036                },
12037            );
12038        }
12039    }
12040
12041    priors.into_values().collect()
12042}
12043
12044fn kokoro_runtime_fallback_enabled() -> bool {
12045    std::env::var("CAR_SPEECH_KOKORO_FALLBACK")
12046        .ok()
12047        .map(|value| {
12048            !matches!(
12049                value.trim().to_ascii_lowercase().as_str(),
12050                "0" | "false" | "off"
12051            )
12052        })
12053        .unwrap_or(true)
12054}
12055
12056fn speech_runtime_mlx_audio_spec() -> String {
12057    std::env::var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC")
12058        .ok()
12059        .filter(|value| !value.trim().is_empty())
12060        .unwrap_or_else(|| "mlx-audio==0.4.2".to_string())
12061}
12062
12063fn speech_runtime_spacy_model_spec() -> String {
12064    std::env::var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC")
12065        .ok()
12066        .filter(|value| !value.trim().is_empty())
12067        .unwrap_or_else(|| {
12068            "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl".to_string()
12069        })
12070}
12071
12072#[cfg(test)]
12073pub(crate) fn run_in_isolated_test_process(test_name: &str, sentinel: &str) -> bool {
12074    static CHILD_PROCESS_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
12075
12076    if std::env::var_os(sentinel).is_some() {
12077        return true;
12078    }
12079
12080    let _child_process = CHILD_PROCESS_MUTEX
12081        .lock()
12082        .unwrap_or_else(std::sync::PoisonError::into_inner);
12083    let mut command = std::process::Command::new(std::env::current_exe().unwrap());
12084    command
12085        .arg("--exact")
12086        .arg(test_name)
12087        .arg("--nocapture")
12088        .arg("--test-threads=1")
12089        .env(sentinel, "1");
12090    for name in [
12091        car_home::ENV_VAR,
12092        "CAR_SECRETS_FILE_DIR",
12093        "CAR_AUTH_LOCK_PATH",
12094        car_auth::PARSLEE_ACCESS_TOKEN_KEY,
12095        car_auth::PARSLEE_API_BASE_KEY,
12096        crate::openrouter::API_KEY_ENV,
12097        "OPENAI_API_KEY",
12098        "ANTHROPIC_API_KEY",
12099        "GOOGLE_API_KEY",
12100        "ELEVENLABS_API_KEY",
12101        "SSL_CERT_FILE",
12102        "SSL_CERT_DIR",
12103    ] {
12104        command.env_remove(name);
12105    }
12106    let output = command.output().expect("spawn isolated inference test");
12107    assert!(
12108        output.status.success(),
12109        "isolated {test_name} failed\nstdout:\n{}\nstderr:\n{}",
12110        String::from_utf8_lossy(&output.stdout),
12111        String::from_utf8_lossy(&output.stderr),
12112    );
12113    false
12114}
12115
12116#[cfg(test)]
12117mod tests {
12118    use super::*;
12119    use std::ffi::OsString;
12120    use tempfile::TempDir;
12121
12122    /// car#851: `car do --local --model mlx/qwen3-8b:4bit` hung for 57 minutes.
12123    /// The caller asked for the default 4096-token budget and the routing layer
12124    /// silently widened it to the model's advertised 32768 — which for a model
12125    /// decoded in-process is not a token budget, it is ~24 minutes of wall
12126    /// clock. Remote models still get the widening; that is what keeps a
12127    /// long-horizon tool_use argument from truncating mid-object.
12128    #[test]
12129    fn local_models_keep_the_callers_default_output_budget() {
12130        use crate::tasks::generate::DEFAULT_MAX_TOKENS;
12131
12132        let catalog = crate::registry::builtin_catalog();
12133        let local = catalog
12134            .iter()
12135            .find(|s| s.id == "mlx/qwen3-8b:4bit")
12136            .expect("mlx/qwen3-8b:4bit is a builtin catalog entry");
12137
12138        assert!(local.is_local(), "mlx/* is decoded in-process");
12139        assert_eq!(
12140            local.effective_max_output(),
12141            32_768,
12142            "the budget that made the reported turn ~24 minutes long"
12143        );
12144        assert_eq!(
12145            resolved_max_tokens(DEFAULT_MAX_TOKENS, local),
12146            DEFAULT_MAX_TOKENS,
12147            "a local model must keep the budget the caller actually asked for"
12148        );
12149
12150        // Same numbers, only the locality differs — so the widening below can
12151        // only be attributable to the model being remote.
12152        let mut remote = catalog
12153            .iter()
12154            .find(|s| !s.is_local())
12155            .expect("the builtin catalog ships remote models")
12156            .clone();
12157        remote.context_length = local.context_length;
12158        remote.max_output_tokens = local.max_output_tokens;
12159        assert!(!remote.is_local());
12160        assert_eq!(
12161            resolved_max_tokens(DEFAULT_MAX_TOKENS, &remote),
12162            32_768,
12163            "remote models still get their advertised output budget"
12164        );
12165
12166        // An explicit budget is a caller decision; never second-guess it.
12167        assert_eq!(resolved_max_tokens(512, local), 512);
12168        assert_eq!(resolved_max_tokens(512, &remote), 512);
12169
12170        let codex = catalog
12171            .iter()
12172            .find(|s| s.id == "openai/gpt-5.6-sol:high")
12173            .expect("subscription-backed Codex row is builtin");
12174        assert_eq!(
12175            resolved_max_tokens(DEFAULT_MAX_TOKENS, codex),
12176            DEFAULT_MAX_TOKENS,
12177            "an approximate instruction must not widen the default to 128K tokens"
12178        );
12179
12180        // vLLM-MLX is `is_local()` but we do not decode it — it is an HTTP
12181        // server on this machine, and it is the documented route to structured
12182        // tool calls from a local model, so it must KEEP the widening. Gating
12183        // this helper on `is_local` instead of `decodes_in_process` silently
12184        // reintroduces the tool_use truncation the widening exists to prevent.
12185        if let Some(vllm) = catalog.iter().find(|s| s.is_vllm_mlx()) {
12186            assert!(vllm.is_local(), "vLLM-MLX runs on this machine");
12187            assert!(
12188                !vllm.decodes_in_process(),
12189                "but CAR does not decode it token by token"
12190            );
12191            assert_eq!(
12192                resolved_max_tokens(DEFAULT_MAX_TOKENS, vllm),
12193                vllm.effective_max_output(),
12194            );
12195        }
12196    }
12197
12198    /// The decision that stops car#851 from paying its ceiling twice, and the
12199    /// one that stops a dead turn from reading as a successful empty one.
12200    #[test]
12201    fn a_ceiling_stop_fails_instead_of_retrying() {
12202        use EmptyPassAction::*;
12203        const CEILING: Option<&str> = Some(LOCAL_DECODE_TIMEOUT_STOP_REASON);
12204
12205        // The car#851 doubling: empty text + Auto looks exactly like a thinking
12206        // truncation, and retrying would spend the same ceiling again.
12207        assert_eq!(
12208            classify_empty_pass(true, CEILING, "", true),
12209            FailDecodeCeiling
12210        );
12211        assert_eq!(
12212            classify_empty_pass(false, CEILING, "", true),
12213            FailDecodeCeiling
12214        );
12215        // Whitespace is not output.
12216        assert_eq!(
12217            classify_empty_pass(true, CEILING, "  \n ", true),
12218            FailDecodeCeiling
12219        );
12220
12221        // The pre-existing car-releases#60 recovery still fires when the
12222        // ceiling is NOT the reason.
12223        assert_eq!(
12224            classify_empty_pass(true, Some("length"), "", true),
12225            RetryWithoutThinking
12226        );
12227        assert_eq!(
12228            classify_empty_pass(true, None, "", true),
12229            RetryWithoutThinking
12230        );
12231
12232        // Caller opted out of recovery and no ceiling: accept the empty result
12233        // rather than inventing a retry or an error.
12234        assert_eq!(classify_empty_pass(false, Some("stop"), "", true), Accept);
12235
12236        // A partial answer beats an error — a ceiling stop that produced text
12237        // or a tool call is still usable output.
12238        assert_eq!(classify_empty_pass(true, CEILING, "partial", true), Accept);
12239        assert_eq!(classify_empty_pass(true, CEILING, "", false), Accept);
12240
12241        // Collision guard: a remote provider's raw finish_reason of "timeout"
12242        // must NOT be mistaken for the local ceiling and converted into an
12243        // error naming a local wall clock and a local env var.
12244        assert_ne!(LOCAL_DECODE_TIMEOUT_STOP_REASON, "timeout");
12245        assert_eq!(
12246            classify_empty_pass(true, Some("timeout"), "", true),
12247            RetryWithoutThinking
12248        );
12249    }
12250
12251    /// Both decode loops share these two predicates. The streaming loop runs
12252    /// only against a real MLX backend, so this is the regression net for an
12253    /// inverted comparison or a heartbeat that forgets to advance. (car#851)
12254    #[test]
12255    fn decode_deadline_and_heartbeat_predicates() {
12256        use std::time::Duration;
12257        let limit = Duration::from_secs(300);
12258
12259        // No ceiling configured: never fires, however long it runs.
12260        assert!(!deadline_exceeded(Duration::from_secs(86_400), None));
12261
12262        assert!(!deadline_exceeded(Duration::from_secs(299), Some(limit)));
12263        // Inclusive at the boundary — `>`, not `>=`, would let an exactly-at-
12264        // limit decode run one more forward.
12265        assert!(deadline_exceeded(limit, Some(limit)));
12266        assert!(deadline_exceeded(Duration::from_secs(301), Some(limit)));
12267
12268        let every = Duration::from_secs(10);
12269        // First heartbeat: `last` starts at ZERO.
12270        assert!(!heartbeat_due(
12271            Duration::from_secs(9),
12272            Duration::ZERO,
12273            every
12274        ));
12275        assert!(heartbeat_due(
12276            Duration::from_secs(10),
12277            Duration::ZERO,
12278            every
12279        ));
12280        // After firing, `last` advances — no flood on the next token.
12281        assert!(!heartbeat_due(
12282            Duration::from_secs(11),
12283            Duration::from_secs(10),
12284            every
12285        ));
12286        assert!(heartbeat_due(
12287            Duration::from_secs(20),
12288            Duration::from_secs(10),
12289            every
12290        ));
12291        // saturating_sub: a `last` ahead of `elapsed` must not panic.
12292        assert!(!heartbeat_due(
12293            Duration::from_secs(5),
12294            Duration::from_secs(10),
12295            every
12296        ));
12297    }
12298
12299    #[test]
12300    fn decode_timeout_parses_with_a_safe_fallback() {
12301        let default = std::time::Duration::from_secs(DEFAULT_LOCAL_DECODE_TIMEOUT_SECS);
12302        assert_eq!(parse_decode_timeout(None), Some(default));
12303        assert_eq!(
12304            parse_decode_timeout(Some(" 45 ")),
12305            Some(std::time::Duration::from_secs(45))
12306        );
12307        assert_eq!(
12308            parse_decode_timeout(Some("0")),
12309            None,
12310            "0 disables the ceiling"
12311        );
12312        assert_eq!(
12313            parse_decode_timeout(Some("banana")),
12314            Some(default),
12315            "garbage must fall back to the default, never silently disable it"
12316        );
12317    }
12318
12319    /// The other half of car#851: even with a sane budget, the decode loop's
12320    /// only bound was `max_tokens`, and it logged nothing for its whole
12321    /// duration — so a runaway was indistinguishable from a wedged process.
12322    /// A decoder that never emits an eos id must now come back on the clock.
12323    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12324    #[test]
12325    fn local_decode_stops_at_the_wall_clock_ceiling() {
12326        /// Always samples token 7; 0 is the only eos id, so this never stops
12327        /// on its own. 5 ms per forward stands in for a large slow model.
12328        struct NeverStops;
12329
12330        impl crate::backend::local::TextDecoder for NeverStops {
12331            fn encode(&self, _text: &str) -> Result<Vec<u32>, InferenceError> {
12332                Ok(vec![1, 2, 3])
12333            }
12334            fn decode(&self, tokens: &[u32]) -> Result<String, InferenceError> {
12335                Ok("x".repeat(tokens.len()))
12336            }
12337            fn forward(
12338                &mut self,
12339                _tokens: &[u32],
12340                _pos: usize,
12341            ) -> Result<Vec<f32>, InferenceError> {
12342                std::thread::sleep(std::time::Duration::from_millis(5));
12343                let mut logits = vec![0.0f32; 16];
12344                logits[7] = 10.0;
12345                Ok(logits)
12346            }
12347            fn eos_ids(&self) -> Vec<u32> {
12348                vec![0]
12349            }
12350            fn context_length(&self) -> usize {
12351                4096
12352            }
12353            fn clear_kv_cache(&mut self) {}
12354        }
12355
12356        // 100_000 tokens at 5 ms each is over eight minutes of decode if the
12357        // budget is the only thing bounding the loop.
12358        let params = GenerateParams {
12359            max_tokens: 100_000,
12360            temperature: 0.0,
12361            ..Default::default()
12362        };
12363        let ceiling = std::time::Duration::from_millis(300);
12364
12365        let mut backend = NeverStops;
12366        let started = std::time::Instant::now();
12367        let generated = match InferenceEngine::drive_generation_with_timeout(
12368            &mut backend,
12369            "anything",
12370            &params,
12371            Some(ceiling),
12372        ) {
12373            Ok(generated) => generated,
12374            // `DriveError` is not `Debug`, so unwrap the inner error by hand.
12375            Err(e) => panic!(
12376                "a decode cut short by the ceiling returns its partial text, not an error: {}",
12377                e.into_inner()
12378            ),
12379        };
12380        let elapsed = started.elapsed();
12381
12382        assert_eq!(
12383            generated.stop_reason.as_deref(),
12384            Some(LOCAL_DECODE_TIMEOUT_STOP_REASON),
12385            "the caller must be able to tell a deadline stop from a clean finish"
12386        );
12387        assert!(
12388            InferenceResult {
12389                text: generated.text.clone(),
12390                bounding_boxes: Vec::new(),
12391                tool_calls: vec![],
12392                trace_id: String::new(),
12393                model_used: String::new(),
12394                model_identity: Default::default(),
12395                latency_ms: 0,
12396                time_to_first_token_ms: None,
12397                usage: None,
12398                provider_output_items: Vec::new(),
12399                thinking: Vec::new(),
12400                stop_reason: generated.stop_reason.clone(),
12401                auth_fallback_from: None,
12402                local_last_resort: false,
12403                fallback_from: Vec::new(),
12404            }
12405            .was_truncated(),
12406            "a ceiling stop is a cut-short answer, not a complete one"
12407        );
12408        assert!(
12409            generated.completion_tokens > 0,
12410            "the partial response is kept, not discarded"
12411        );
12412        assert!(
12413            generated.completion_tokens < params.max_tokens,
12414            "the loop stopped on the clock, not by exhausting the budget"
12415        );
12416        assert!(
12417            elapsed < std::time::Duration::from_secs(30),
12418            "bounded in wall clock; took {elapsed:?}"
12419        );
12420    }
12421
12422    struct RestoredEnvironment(Vec<(&'static str, Option<OsString>)>);
12423
12424    impl RestoredEnvironment {
12425        fn capture(names: &[&'static str]) -> Self {
12426            Self(
12427                names
12428                    .iter()
12429                    .map(|name| (*name, std::env::var_os(name)))
12430                    .collect(),
12431            )
12432        }
12433    }
12434
12435    impl Drop for RestoredEnvironment {
12436        fn drop(&mut self) {
12437            for (name, value) in &self.0 {
12438                unsafe {
12439                    match value {
12440                        Some(value) => std::env::set_var(name, value),
12441                        None => std::env::remove_var(name),
12442                    }
12443                }
12444            }
12445        }
12446    }
12447
12448    struct FixtureLocalOffload {
12449        emit_done: bool,
12450    }
12451
12452    // Gated to match its only consumer,
12453    // `exact_model_id_nonstream_bypasses_mlx_equivalent_substitution`. Ungated,
12454    // this whole cluster is dead code everywhere the MLX test is compiled out,
12455    // which `-D warnings` turns into a build failure on the Linux runner.
12456    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12457    struct ExactPinCaptureOffload {
12458        dispatched_models: std::sync::Mutex<Vec<String>>,
12459    }
12460
12461    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12462    impl ExactPinCaptureOffload {
12463        fn new() -> Self {
12464            Self {
12465                dispatched_models: std::sync::Mutex::new(Vec::new()),
12466            }
12467        }
12468
12469        fn dispatched_models(&self) -> Vec<String> {
12470            self.dispatched_models.lock().unwrap().clone()
12471        }
12472    }
12473
12474    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12475    #[async_trait::async_trait]
12476    impl crate::offload::LocalGenerationOffload for ExactPinCaptureOffload {
12477        async fn generate(
12478            &self,
12479            request: GenerateRequest,
12480        ) -> Result<InferenceResult, InferenceError> {
12481            let model_id = request.model.expect("resolved worker model");
12482            self.dispatched_models
12483                .lock()
12484                .unwrap()
12485                .push(model_id.clone());
12486            Ok(InferenceResult {
12487                text: "exact pin".into(),
12488                tool_calls: vec![],
12489                bounding_boxes: vec![],
12490                trace_id: "worker-trace".into(),
12491                model_used: model_id,
12492                model_identity: Default::default(),
12493                latency_ms: 0,
12494                time_to_first_token_ms: None,
12495                usage: None,
12496                provider_output_items: vec![],
12497                thinking: vec![],
12498                stop_reason: Some("stop".into()),
12499                auth_fallback_from: None,
12500                local_last_resort: false,
12501                fallback_from: Vec::new(),
12502            })
12503        }
12504
12505        async fn stream(
12506            &self,
12507            request: GenerateRequest,
12508        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
12509            let model_id = request.model.expect("resolved worker model");
12510            self.dispatched_models.lock().unwrap().push(model_id);
12511            let (tx, rx) = tokio::sync::mpsc::channel(2);
12512            tx.send(StreamEvent::Done {
12513                text: "exact pin".into(),
12514                tool_calls: vec![],
12515            })
12516            .await
12517            .unwrap();
12518            Ok(rx)
12519        }
12520    }
12521
12522    struct ReconcileOffload {
12523        calls: std::sync::Mutex<Vec<String>>,
12524        release_acknowledged: bool,
12525    }
12526
12527    #[async_trait::async_trait]
12528    impl crate::offload::LocalGenerationOffload for ReconcileOffload {
12529        async fn generate(
12530            &self,
12531            _request: GenerateRequest,
12532        ) -> Result<InferenceResult, InferenceError> {
12533            unreachable!("residency reconciliation fixture")
12534        }
12535
12536        async fn stream(
12537            &self,
12538            _request: GenerateRequest,
12539        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
12540            unreachable!("residency reconciliation fixture")
12541        }
12542
12543        fn resident_allocation_id(&self, model_id: &str) -> Option<String> {
12544            self.calls
12545                .lock()
12546                .unwrap()
12547                .push(format!("allocation:{model_id}"));
12548            Some(format!("worker:{model_id}:generation-7"))
12549        }
12550
12551        async fn release_model(&self, model_id: &str) -> Result<bool, InferenceError> {
12552            self.calls
12553                .lock()
12554                .unwrap()
12555                .push(format!("release:{model_id}"));
12556            Ok(self.release_acknowledged)
12557        }
12558    }
12559
12560    fn reconciliation_reservation() -> (
12561        Arc<crate::resource_policy::LocalAdmissionCoordinator>,
12562        crate::resource_policy::LocalLoadReservation,
12563    ) {
12564        struct FixedProbe;
12565        impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
12566            fn available_memory_mb(
12567                &self,
12568            ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
12569                Ok(Some(24_000))
12570            }
12571        }
12572        let coordinator = Arc::new(
12573            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
12574                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
12575                crate::hardware::HardwareInfo {
12576                    total_ram_mb: 32 * 1024,
12577                    ..crate::hardware::HardwareInfo::detect()
12578                },
12579                Arc::new(FixedProbe),
12580            ),
12581        );
12582        let reservation = coordinator
12583            .reserve_measured_host_allocation(
12584                "expected/model",
12585                "expected/model#request",
12586                1024 * 1024,
12587                0,
12588            )
12589            .unwrap();
12590        (coordinator, reservation)
12591    }
12592
12593    #[tokio::test]
12594    async fn worker_residency_reconciliation_releases_mismatched_ack_before_returning_error() {
12595        let offload = ReconcileOffload {
12596            calls: std::sync::Mutex::new(Vec::new()),
12597            release_acknowledged: true,
12598        };
12599        let (_coordinator, mut reservation) = reconciliation_reservation();
12600        let error = InferenceEngine::reconcile_worker_residency(
12601            &offload,
12602            "expected/model",
12603            &crate::offload::LocalWorkerResidency {
12604                model_id: "wrong/model".into(),
12605                measured_weights_bytes: 2 * 1024 * 1024,
12606            },
12607            backend_cache::BackendRetention::Resident,
12608            &mut reservation,
12609        )
12610        .await
12611        .unwrap_err();
12612        assert!(error.to_string().contains("wrong/model"));
12613        assert_eq!(
12614            *offload.calls.lock().unwrap(),
12615            vec!["release:wrong/model".to_string()]
12616        );
12617    }
12618
12619    #[tokio::test]
12620    async fn worker_residency_reconciliation_publishes_exact_owner_for_matching_ack() {
12621        let offload = ReconcileOffload {
12622            calls: std::sync::Mutex::new(Vec::new()),
12623            release_acknowledged: false,
12624        };
12625        let (coordinator, mut reservation) = reconciliation_reservation();
12626        InferenceEngine::reconcile_worker_residency(
12627            &offload,
12628            "expected/model",
12629            &crate::offload::LocalWorkerResidency {
12630                model_id: "expected/model".into(),
12631                measured_weights_bytes: 2 * 1024 * 1024,
12632            },
12633            backend_cache::BackendRetention::Resident,
12634            &mut reservation,
12635        )
12636        .await
12637        .unwrap();
12638        drop(reservation);
12639        assert_eq!(
12640            *offload.calls.lock().unwrap(),
12641            vec!["allocation:expected/model".to_string()]
12642        );
12643        assert!(coordinator.is_resident("expected/model"));
12644    }
12645
12646    #[async_trait::async_trait]
12647    impl crate::offload::LocalGenerationOffload for FixtureLocalOffload {
12648        async fn generate(
12649            &self,
12650            _request: GenerateRequest,
12651        ) -> Result<InferenceResult, InferenceError> {
12652            unreachable!("streaming fixture")
12653        }
12654
12655        async fn stream(
12656            &self,
12657            _request: GenerateRequest,
12658        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
12659            unreachable!("admission-aware streaming fixture")
12660        }
12661
12662        async fn stream_admitted(
12663            &self,
12664            request: GenerateRequest,
12665            admission: crate::offload::LocalWorkerAdmission,
12666        ) -> Result<crate::offload::LocalOffloadStream, InferenceError> {
12667            let (tx, rx) = tokio::sync::mpsc::channel(4);
12668            let emit_done = self.emit_done;
12669            tokio::spawn(async move {
12670                let _ = tx.send(StreamEvent::TextDelta("local answer".into())).await;
12671                if emit_done {
12672                    let _ = tx
12673                        .send(StreamEvent::Done {
12674                            text: "local answer".into(),
12675                            tool_calls: vec![],
12676                        })
12677                        .await;
12678                }
12679            });
12680            Ok(crate::offload::LocalOffloadStream {
12681                events: rx,
12682                residency: crate::offload::LocalWorkerResidency {
12683                    model_id: request.model.unwrap_or_else(|| "fixture/local".into()),
12684                    measured_weights_bytes: admission.measured_weights_bytes,
12685                },
12686                retention: backend_cache::BackendRetention::Resident,
12687            })
12688        }
12689    }
12690
12691    /// Pin the engine's local-admission live-memory probe to a fixed, ample
12692    /// value. Tests that stream through a LOCAL fixture model are exercising
12693    /// stream/outcome mechanics, not host admission — with the real
12694    /// SystemLiveMemoryProbe, a concurrent cargo build's rustc processes can
12695    /// push live free memory under the emergency reserve and turn the setup
12696    /// into LocalResourceBlocked/InsufficientLiveMemory (car-sap7: measured
12697    /// under a parallel build with 8326 MB live against a 6553 MB reserve
12698    /// plus a 2037 MB estimate). Policy and hardware stay exactly as the
12699    /// engine built them; only the live probe is pinned.
12700    fn pin_test_live_memory(engine: &mut InferenceEngine) {
12701        let policy = engine.local_admission.policy();
12702        engine.local_admission = Arc::new(resource_policy::LocalAdmissionCoordinator::with_probe(
12703            policy,
12704            HardwareInfo::detect(),
12705            Arc::new(resource_policy::FixedLiveMemoryProbe::known(1_048_576)),
12706        ));
12707    }
12708
12709    fn install_small_local_fixture(engine: &InferenceEngine) -> String {
12710        let schema = engine
12711            .unified_registry
12712            .find_by_name("Qwen3-0.6B")
12713            .expect("small built-in local model")
12714            .clone();
12715        let model_dir = engine.config.models_dir.join(&schema.name);
12716        std::fs::create_dir_all(&model_dir).unwrap();
12717        std::fs::write(model_dir.join("model.gguf"), b"fixture").unwrap();
12718        std::fs::write(model_dir.join("tokenizer.json"), b"{}").unwrap();
12719        schema.id
12720    }
12721
12722    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12723    fn install_exact_pin_equivalent_fixture(engine: &InferenceEngine) -> String {
12724        let gguf = engine
12725            .unified_registry
12726            .get("qwen/qwen3-0.6b:q8_0")
12727            .expect("GGUF fixture row")
12728            .clone();
12729        let mlx = engine
12730            .unified_registry
12731            .get("mlx/qwen3-0.6b:6bit")
12732            .expect("MLX equivalent fixture row")
12733            .clone();
12734
12735        let gguf_dir = engine.config.models_dir.join(&gguf.name);
12736        std::fs::create_dir_all(&gguf_dir).unwrap();
12737        std::fs::write(gguf_dir.join("model.gguf"), b"fixture").unwrap();
12738        std::fs::write(gguf_dir.join("tokenizer.json"), b"{}").unwrap();
12739
12740        let mlx_dir = engine.config.models_dir.join(&mlx.name);
12741        std::fs::create_dir_all(&mlx_dir).unwrap();
12742        std::fs::write(mlx_dir.join("config.json"), b"{}").unwrap();
12743        std::fs::write(mlx_dir.join("model.safetensors"), b"fixture").unwrap();
12744
12745        gguf.id
12746    }
12747
12748    fn remote_stream_fixture_schema(
12749        id: &str,
12750        endpoint: String,
12751        protocol: schema::ApiProtocol,
12752        api_key_env: &str,
12753    ) -> ModelSchema {
12754        ModelSchema {
12755            id: id.into(),
12756            name: "gemini-test".into(),
12757            provider: "test".into(),
12758            family: "test".into(),
12759            version: "1".into(),
12760            capabilities: vec![ModelCapability::Generate],
12761            context_length: 128_000,
12762            max_output_tokens: Some(8_192),
12763            param_count: String::new(),
12764            quantization: None,
12765            performance: Default::default(),
12766            cost: Default::default(),
12767            source: ModelSource::RemoteApi {
12768                endpoint,
12769                api_key_env: api_key_env.into(),
12770                api_key_envs: vec![],
12771                api_version: None,
12772                protocol,
12773            },
12774            tags: vec!["test".into()],
12775            supported_params: vec![],
12776            public_benchmarks: vec![],
12777            trust_tier: TrustTier::Community,
12778            deprecated: false,
12779            available: true,
12780            weights_ready: true,
12781        }
12782    }
12783
12784    async fn assert_remote_model_identity_contract(
12785        protocol: schema::ApiProtocol,
12786        model_id: &str,
12787        provider_model: &str,
12788        api_key_env: &str,
12789    ) {
12790        use wiremock::matchers::{method, path};
12791        use wiremock::{Mock, MockServer, ResponseTemplate};
12792
12793        let server = MockServer::start().await;
12794        let (endpoint_path, response) = match protocol {
12795            schema::ApiProtocol::OpenAiCompat => (
12796                "/v1/chat/completions",
12797                serde_json::json!({
12798                    "choices": [{
12799                        "message": {"content": "openai exact"},
12800                        "finish_reason": "stop"
12801                    }],
12802                    "usage": {"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4}
12803                }),
12804            ),
12805            schema::ApiProtocol::Anthropic => (
12806                "/v1/messages",
12807                serde_json::json!({
12808                    "content": [{"type": "text", "text": "anthropic exact"}],
12809                    "stop_reason": "end_turn",
12810                    "usage": {"input_tokens": 2, "output_tokens": 2}
12811                }),
12812            ),
12813            _ => unreachable!("identity regression covers the newsroom's two remote providers"),
12814        };
12815        Mock::given(method("POST"))
12816            .and(path(endpoint_path))
12817            .respond_with(ResponseTemplate::new(200).set_body_json(response))
12818            .mount(&server)
12819            .await;
12820        unsafe { std::env::set_var(api_key_env, "fixture") };
12821
12822        let tmp = TempDir::new().unwrap();
12823        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
12824        let mut schema =
12825            remote_stream_fixture_schema(model_id, server.uri(), protocol, api_key_env);
12826        schema.name = provider_model.into();
12827        engine.register_model(schema);
12828
12829        let mut exact_request = GenerateRequest {
12830            prompt: "return the exact identity".into(),
12831            ..Default::default()
12832        };
12833        pin_exact_model_id(&mut exact_request, model_id.into()).unwrap();
12834        let exact = engine.generate_tracked(exact_request).await.unwrap();
12835        assert_eq!(exact.model_used, model_id);
12836        assert_eq!(
12837            exact.model_identity.requested_model_id.as_deref(),
12838            Some(model_id)
12839        );
12840        assert_eq!(exact.model_identity.resolved_model_id, model_id);
12841        let exact_envelope = serde_json::to_value(&exact).unwrap();
12842        assert_eq!(exact_envelope["model_used"], model_id);
12843        assert_eq!(exact_envelope["requested_model_id"], model_id);
12844        assert_eq!(exact_envelope["resolved_model_id"], model_id);
12845        assert!(exact_envelope["row_digest"].is_string());
12846        assert!(exact_envelope["catalog_revision"].is_string());
12847
12848        let loose = engine
12849            .generate_tracked(GenerateRequest {
12850                prompt: "keep legacy display-name routing".into(),
12851                model: Some(provider_model.into()),
12852                params: GenerateParams {
12853                    strict_model: true,
12854                    ..Default::default()
12855                },
12856                ..Default::default()
12857            })
12858            .await
12859            .unwrap();
12860        assert_eq!(loose.model_used, provider_model);
12861        assert_eq!(loose.model_identity.requested_model_id, None);
12862        assert_eq!(loose.model_identity.resolved_model_id, model_id);
12863
12864        let requests = server.received_requests().await.unwrap();
12865        assert_eq!(requests.len(), 2);
12866        for request in requests {
12867            let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
12868            assert_eq!(body["model"], provider_model);
12869        }
12870
12871        unsafe { std::env::remove_var(api_key_env) };
12872    }
12873
12874    /// The Metal device lock (`mlx_device_lock`) must be a process-wide singleton
12875    /// AND grant only one holder at a time — that is what serializes every local
12876    /// MLX path (coder generate, streaming, embedding/consolidation) onto the one
12877    /// Metal device so a background consolidation embed can't run concurrently
12878    /// with a coder generate and wedge the device (the daemon "wedge" this fixes).
12879    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
12880    #[tokio::test]
12881    async fn mlx_device_lock_is_singleton_and_serializes() {
12882        use std::sync::atomic::{AtomicUsize, Ordering};
12883        use std::sync::Arc;
12884        // Same underlying mutex across calls (so all MLX paths share one permit).
12885        assert!(
12886            Arc::ptr_eq(
12887                &InferenceEngine::mlx_device_lock(),
12888                &InferenceEngine::mlx_device_lock()
12889            ),
12890            "device lock must be a process-wide singleton"
12891        );
12892        // Mutual exclusion: never more than one holder concurrently.
12893        let inside = Arc::new(AtomicUsize::new(0));
12894        let peak = Arc::new(AtomicUsize::new(0));
12895        let mut handles = Vec::new();
12896        for _ in 0..8 {
12897            let inside = inside.clone();
12898            let peak = peak.clone();
12899            handles.push(tokio::spawn(async move {
12900                let _g = InferenceEngine::mlx_device_lock().lock_owned().await;
12901                let n = inside.fetch_add(1, Ordering::SeqCst) + 1;
12902                peak.fetch_max(n, Ordering::SeqCst);
12903                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
12904                inside.fetch_sub(1, Ordering::SeqCst);
12905            }));
12906        }
12907        for h in handles {
12908            h.await.unwrap();
12909        }
12910        assert_eq!(
12911            peak.load(Ordering::SeqCst),
12912            1,
12913            "at most one MLX device holder at a time"
12914        );
12915    }
12916
12917    /// The F1 auto-thinking gate decision: a coding intent gets "high" (24000),
12918    /// a general Complex turn gets "medium" (8000), and thinking is skipped
12919    /// entirely when the model can't do it — so a non-thinking model never gets
12920    /// a budget that would 400.
12921    #[test]
12922    fn auto_thinking_budget_gates_code_complex_and_capability() {
12923        // Coding intent on a thinking-capable model -> high.
12924        assert_eq!(auto_thinking_budget(true, false, true), Some(24_000));
12925        // General Complex on a thinking-capable model -> medium.
12926        assert_eq!(auto_thinking_budget(false, true, true), Some(8_000));
12927        // Code takes precedence over Complex.
12928        assert_eq!(auto_thinking_budget(true, true, true), Some(24_000));
12929        // Model can't think -> None even for a coding turn (no budget -> no 400).
12930        assert_eq!(auto_thinking_budget(true, true, false), None);
12931        // Neither coding nor complex -> None (plain turns never auto-think).
12932        assert_eq!(auto_thinking_budget(false, false, true), None);
12933    }
12934
12935    #[test]
12936    fn is_explicit_code_intent_keys_on_caller_intent_not_keyword_classifier() {
12937        use crate::intent::{IntentHint, TaskHint};
12938        // The coder/bench set an explicit Code intent — the gate fires.
12939        let code = IntentHint {
12940            task: Some(TaskHint::Code),
12941            ..Default::default()
12942        };
12943        assert!(is_explicit_code_intent(Some(&code)));
12944        // No caller intent -> NOT code, even if the prompt's keyword-classified
12945        // decision.task would be Code. This is the exact over-provisioning the
12946        // gate avoids: a re-key onto decision.task would light up high-effort
12947        // thinking on any prose containing "fix"/"bug"/"let ".
12948        assert!(!is_explicit_code_intent(None));
12949        // A different explicit task is not code.
12950        let reasoning = IntentHint {
12951            task: Some(TaskHint::Reasoning),
12952            ..Default::default()
12953        };
12954        assert!(!is_explicit_code_intent(Some(&reasoning)));
12955        // Intent present but task unset -> NOT code (matches the no-intent path).
12956        let unset = IntentHint {
12957            task: None,
12958            ..Default::default()
12959        };
12960        assert!(!is_explicit_code_intent(Some(&unset)));
12961    }
12962
12963    #[test]
12964    fn strict_model_suppresses_the_local_last_resort_append() {
12965        // Loose (default) remote-only chain → append a local model (resilience).
12966        assert!(should_append_local_last_resort(false, false));
12967        // Hard pin, remote-only chain → do NOT append: the pinned remote model
12968        // must fail loudly, not silently degrade to a weaker local model.
12969        assert!(!should_append_local_last_resort(false, true));
12970        // A chain that already has a local model never needs the last resort,
12971        // strict or not.
12972        assert!(!should_append_local_last_resort(true, false));
12973        assert!(!should_append_local_last_resort(true, true));
12974    }
12975
12976    #[test]
12977    fn last_resort_fallback_never_returns_an_unrunnable_apple_foundation() {
12978        // Regression: apple-foundation is a builtin that is `is_local()` and
12979        // `ready_without_download == Some(true)` on EVERY platform (there is
12980        // nothing to download), but it only executes on Apple Silicon. The
12981        // last-resort local-fallback append used `ready_without_download` alone,
12982        // so off-Apple it handed back `apple-foundation`; the attempt then failed
12983        // with `model not found: apple-foundation`, and — being the last candidate
12984        // — that error MASKED the real remote failure. (Found on Windows when a
12985        // CRLF-corrupted SSE fixture made the managed primary fail; the surfaced
12986        // error blamed apple-foundation, not the fixture.)
12987        //
12988        // Platform-agnostic invariant: whatever the last resort picks, it must be
12989        // runnable here — on Apple apple-foundation is `available` and stays
12990        // eligible; off-Apple it is excluded. Empty models dir ⇒ off-Apple this is
12991        // simply `None`.
12992        let tmp = TempDir::new().unwrap();
12993        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
12994        if let Some(name) = engine.first_installed_local_model(false) {
12995            let runnable = engine
12996                .unified_registry
12997                .find_by_name(&name)
12998                .map(|s| !s.is_foundation_models() || s.available)
12999                .unwrap_or(false);
13000            assert!(
13001                runnable,
13002                "last-resort fallback returned a non-runnable model: {name}"
13003            );
13004        }
13005    }
13006
13007    #[test]
13008    fn empty_tool_catalog_is_no_tools() {
13009        // `tools: Some(vec![])` must behave exactly like `tools: None`:
13010        // no ToolUse routing requirement, and the FoundationModels
13011        // dispatch takes the structured-output path when a JsonSchema
13012        // response_format is present instead of firing the tool path
13013        // (which would warn about — and drop — the schema constraint
13014        // for zero tools).
13015        let mut req = GenerateRequest {
13016            prompt: "p".into(),
13017            ..Default::default()
13018        };
13019        assert!(!InferenceEngine::request_has_tools(&req));
13020        req.tools = Some(vec![]);
13021        assert!(!InferenceEngine::request_has_tools(&req));
13022        req.tools = Some(vec![serde_json::json!({
13023            "name": "t", "description": "d", "parameters": {"type": "object"}
13024        })]);
13025        assert!(InferenceEngine::request_has_tools(&req));
13026    }
13027
13028    #[test]
13029    fn top_k_keeps_only_k_highest() {
13030        // Probs for 5 tokens; top_k=2 keeps the two largest, renormalized.
13031        let mut probs = vec![0.1, 0.4, 0.2, 0.25, 0.05];
13032        InferenceEngine::apply_top_k_top_p(&mut probs, 2, 1.0);
13033        // 0.4 (idx1) and 0.25 (idx3) survive; others zeroed.
13034        assert!(probs[0] == 0.0 && probs[2] == 0.0 && probs[4] == 0.0);
13035        assert!(probs[1] > 0.0 && probs[3] > 0.0);
13036        let sum: f32 = probs.iter().sum();
13037        assert!((sum - 1.0).abs() < 1e-5, "renormalized to 1.0, got {sum}");
13038    }
13039
13040    #[test]
13041    fn top_k_zero_is_a_noop() {
13042        let mut probs = vec![0.1, 0.4, 0.2, 0.3];
13043        let before = probs.clone();
13044        InferenceEngine::apply_top_k_top_p(&mut probs, 0, 1.0);
13045        assert_eq!(probs, before);
13046    }
13047
13048    #[test]
13049    fn top_p_nucleus_truncates_tail() {
13050        let mut probs = vec![0.6, 0.3, 0.07, 0.03];
13051        InferenceEngine::apply_top_k_top_p(&mut probs, 0, 0.9);
13052        // 0.6 + 0.3 = 0.9 crosses the threshold at the 2nd token; tail zeroed.
13053        assert!(probs[2] == 0.0 && probs[3] == 0.0);
13054        assert!(probs[0] > 0.0 && probs[1] > 0.0);
13055    }
13056
13057    #[test]
13058    fn truncate_at_stop_excludes_stop_sequence() {
13059        let stops = vec!["<|end|>".to_string(), "STOP".to_string()];
13060        assert_eq!(
13061            tasks::generate::truncate_at_stop("hello world<|end|>extra", &stops),
13062            "hello world"
13063        );
13064        // Earliest match wins.
13065        assert_eq!(
13066            tasks::generate::truncate_at_stop("aSTOPb<|end|>c", &stops),
13067            "a"
13068        );
13069        // No match -> unchanged.
13070        assert_eq!(
13071            tasks::generate::truncate_at_stop("clean output", &stops),
13072            "clean output"
13073        );
13074        // Empty stop entries ignored.
13075        assert_eq!(
13076            tasks::generate::truncate_at_stop("text", &["".to_string()]),
13077            "text"
13078        );
13079    }
13080
13081    #[test]
13082    fn no_backend_hint_fires_on_missing_backend_phrases() {
13083        // The four phrases the engine emits when nothing is runnable.
13084        for phrase in [
13085            "no credential for proprietary provider 'parslee'",
13086            "model not found",
13087            "no models available",
13088            "model declares ModelSource::Delegated but no inference runner is registered",
13089        ] {
13090            let hint = no_backend_recovery_hint(phrase)
13091                .unwrap_or_else(|| panic!("expected a hint for {phrase:?}"));
13092            assert!(hint.contains("car models pull"));
13093            // The CLI verb is `car auth login` (no `parslee` positional — that
13094            // was a stale doc-ism the hint used to print).
13095            assert!(hint.contains("car auth login"));
13096            // Underlying error is preserved for diagnosis.
13097            assert!(hint.contains(phrase));
13098        }
13099    }
13100
13101    /// Parslee-ai/car#797 item 2 — the credential failure is matchable as DATA,
13102    /// not by substring-matching English that can be reworded at any time.
13103    ///
13104    /// The distinction that matters to a consumer: a token that aged out
13105    /// mid-run is a *resumable* condition for anything that can checkpoint,
13106    /// while a signed-out account is a hard stop, and an unreadable keychain is
13107    /// neither (re-authenticating does not help it).
13108    #[test]
13109    fn credential_failure_is_matchable_as_data() {
13110        let expired = InferenceError::CredentialUnavailable {
13111            provider: "parslee".into(),
13112            model: "parslee/reasoning".into(),
13113            reason: CredentialFailure::Expired {
13114                expires_at: 1_754_257_929,
13115            },
13116            detail: "the Parslee token expired at unix 1754257929 and could not be refreshed"
13117                .into(),
13118        };
13119        let InferenceError::CredentialUnavailable { reason, .. } = &expired else {
13120            panic!("expected CredentialUnavailable");
13121        };
13122        assert_eq!(
13123            *reason,
13124            CredentialFailure::Expired {
13125                expires_at: 1_754_257_929
13126            },
13127            "a consumer must be able to branch on the expiry without parsing prose"
13128        );
13129        // The four failure modes are distinct values, because each has a
13130        // different remedy and collapsing any two would send a user to the
13131        // wrong one.
13132        assert_ne!(
13133            CredentialFailure::SignedOut,
13134            CredentialFailure::StoreUnreadable
13135        );
13136        assert_ne!(
13137            CredentialFailure::SignedOut,
13138            CredentialFailure::Expired { expires_at: 0 }
13139        );
13140        assert_ne!(
13141            CredentialFailure::StoreUnreadable,
13142            CredentialFailure::RaceRetryable
13143        );
13144    }
13145
13146    /// The typed variant must keep rendering the historical prefix, because two
13147    /// downstream classifiers substring-match it.
13148    ///
13149    /// `native_loop::is_auth_failure` drives the wait-for-sign-in path, and the
13150    /// coder-ab harness's `INFRA_MARKERS` keeps auth casualties out of a
13151    /// benchmark denominator. Both look for `no credential for proprietary`.
13152    /// Changing the error from a formatted string to a typed variant is exactly
13153    /// the kind of refactor that silently breaks them, so this pins the
13154    /// rendering rather than trusting the `#[error]` attribute to stay put.
13155    #[test]
13156    fn typed_credential_error_still_satisfies_the_substring_classifiers() {
13157        for reason in [
13158            CredentialFailure::Expired { expires_at: 1 },
13159            CredentialFailure::SignedOut,
13160            CredentialFailure::StoreUnreadable,
13161            CredentialFailure::RaceRetryable,
13162            CredentialFailure::EnvVarMissing {
13163                env_var: "OPENAI_API_KEY".into(),
13164            },
13165        ] {
13166            let rendered = InferenceError::CredentialUnavailable {
13167                provider: "parslee".into(),
13168                model: "parslee/reasoning".into(),
13169                reason: reason.clone(),
13170                detail: "detail text".into(),
13171            }
13172            .to_string();
13173            // `native_loop::is_auth_failure` + coder_ab INFRA_MARKERS.
13174            assert!(
13175                rendered
13176                    .to_ascii_lowercase()
13177                    .contains("no credential for proprietary"),
13178                "classifier substring lost for {reason:?}: {rendered}"
13179            );
13180            // The model is named, so a multi-model run can tell which call died.
13181            assert!(rendered.contains("parslee/reasoning"), "{rendered}");
13182            // And the human-facing detail survives.
13183            assert!(rendered.contains("detail text"), "{rendered}");
13184        }
13185    }
13186
13187    #[test]
13188    fn auth_expired_hint_fires_on_auth_rejection_but_not_transient() {
13189        // Auth-rejection exhaustion → actionable re-auth guidance.
13190        for phrase in [
13191            "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
13192            "HTTP 403: forbidden",
13193            "invalid_grant: The refresh token is invalid or expired",
13194            "token expired",
13195        ] {
13196            let hint = auth_expired_recovery_hint(phrase)
13197                .unwrap_or_else(|| panic!("expected an auth hint for {phrase:?}"));
13198            assert!(hint.contains("car auth login"));
13199            assert!(hint.contains(phrase));
13200        }
13201        // A genuine transient (5xx / timeout) must NOT be classified as auth.
13202        assert!(auth_expired_recovery_hint("API returned 503: service unavailable").is_none());
13203        assert!(auth_expired_recovery_hint("request timed out").is_none());
13204    }
13205
13206    /// The single public definition of "the credential was rejected". Pinned to
13207    /// the LITERAL error an expired Parslee session produces (Parslee-ai/car#888):
13208    /// the coder loop's own matcher missed this exact string, so an expired
13209    /// token burned inference strikes instead of asking for a sign-in.
13210    #[test]
13211    fn auth_rejection_classifier_matches_the_real_expired_token_error() {
13212        assert!(is_auth_rejection_message(
13213            "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
13214             Authentication required"
13215        ));
13216        // A transient is NOT a sign-in problem — telling an operator to
13217        // re-authenticate through a 503 sends them to fix what isn't broken.
13218        for transient in [
13219            "API returned 503: service unavailable",
13220            "request timed out",
13221            "connection reset by peer",
13222        ] {
13223            assert!(
13224                !is_auth_rejection_message(transient),
13225                "transient wrongly classified as an auth rejection: {transient:?}"
13226            );
13227        }
13228    }
13229
13230    /// Fixtures built from the real `InferenceError` values these paths
13231    /// produce, not invented to match the arms.
13232    ///
13233    /// The first version of this test hand-wrote eleven plausible strings and
13234    /// passed, while `ProviderAccount`'s actual Display — the one a refused API
13235    /// key produces — classified as `Failed`. A test whose vocabulary comes
13236    /// from the implementation agrees with it by construction.
13237    #[test]
13238    fn typed_errors_classify_by_their_structure_not_their_prose() {
13239        use FallbackReason as R;
13240        let cases: Vec<(InferenceError, R)> = vec![
13241            // The single most actionable degrade there is. Its Display carries
13242            // no "unauthorized" token, which is what the string rule missed.
13243            (
13244                InferenceError::ProviderAccount {
13245                    provider: "openai".into(),
13246                    status: 401,
13247                    message: "provider rejected the API key — check the configured credential"
13248                        .into(),
13249                },
13250                R::CredentialRejected,
13251            ),
13252            // Out of credits is a billing wait, not a wrong credential.
13253            (
13254                InferenceError::ProviderAccount {
13255                    provider: "openrouter".into(),
13256                    status: 402,
13257                    message: "OpenRouter account is out of credits".into(),
13258                },
13259                // NOT RateLimited: an empty balance does not clear by waiting.
13260                R::QuotaExhausted,
13261            ),
13262            (
13263                InferenceError::CredentialUnavailable {
13264                    provider: "parslee".into(),
13265                    model: "parslee/reasoning".into(),
13266                    reason: CredentialFailure::Expired { expires_at: 0 },
13267                    detail: "session expired".into(),
13268                },
13269                R::CredentialRejected,
13270            ),
13271            (
13272                InferenceError::CredentialUnavailable {
13273                    provider: "openai".into(),
13274                    model: "openai/gpt-5.6".into(),
13275                    reason: CredentialFailure::EnvVarMissing {
13276                        env_var: "OPENAI_API_KEY".into(),
13277                    },
13278                    detail: "not set".into(),
13279                },
13280                R::CredentialAbsent,
13281            ),
13282            // A locked keychain says NOTHING about whether a credential
13283            // exists, so neither absent nor rejected is honest.
13284            (
13285                InferenceError::CredentialUnavailable {
13286                    provider: "parslee".into(),
13287                    model: "parslee/reasoning".into(),
13288                    reason: CredentialFailure::StoreUnreadable,
13289                    detail: "the bounded Keychain helper timed out".into(),
13290                },
13291                R::Failed,
13292            ),
13293            (
13294                InferenceError::Transient {
13295                    status: Some(429),
13296                    message: "slow down".into(),
13297                },
13298                R::RateLimited,
13299            ),
13300            // A statusless transport error is NOT a timeout: the same variant
13301            // carries connection-refused, DNS and TLS failures, and telling
13302            // someone their call timed out when the endpoint was never up
13303            // sends them to raise a timeout instead of starting the runtime.
13304            (
13305                InferenceError::Transient {
13306                    status: None,
13307                    message: "connection reset by peer".into(),
13308                },
13309                R::Failed,
13310            ),
13311            (
13312                InferenceError::Transient {
13313                    status: Some(503),
13314                    message: "upstream down".into(),
13315                },
13316                R::Failed,
13317            ),
13318        ];
13319        for (err, want) in cases {
13320            assert_eq!(classify_fallback_reason(&err), want, "{err}");
13321        }
13322    }
13323
13324    /// A provider's error body is text we did not write. It can quote any
13325    /// status or phrase, and the status line is the only part we control.
13326    ///
13327    /// Both cases below are lifted from defects this workspace already fixed
13328    /// elsewhere: `remote::is_auth_rejection` has a test pinning the 400-that-
13329    /// quotes-401, and `is_provider_transient` anchors on the parsed status
13330    /// because of the 400-whose-message-says-timeout.
13331    #[test]
13332    fn a_quoted_status_in_a_provider_body_does_not_decide_the_bucket() {
13333        use FallbackReason as R;
13334        for (msg, want) in [
13335            (
13336                "API returned 400 Bad Request: your last request 401'd upstream and was unauthorized",
13337                R::Failed,
13338            ),
13339            ("API returned 400 Bad Request: timeout param invalid", R::Failed),
13340            ("API returned 429 Too Many Requests: slow down", R::RateLimited),
13341            ("API returned 401 Unauthorized: bad key", R::CredentialRejected),
13342        ] {
13343            assert_eq!(
13344                classify_fallback_reason(&InferenceError::InferenceFailed(msg.into())),
13345                want,
13346                "{msg}"
13347            );
13348        }
13349    }
13350
13351    /// `Parslee org lookup failed: HTTP <status>` is emitted for ANY non-success
13352    /// status, so the phrase alone is not a dead credential — the producer
13353    /// itself gates `note_credential_rejected()` on 401/403 for this reason.
13354    #[test]
13355    fn a_parslee_org_lookup_failure_is_classified_by_its_status() {
13356        use FallbackReason as R;
13357        for (msg, want) in [
13358            (
13359                "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
13360                R::CredentialRejected,
13361            ),
13362            (
13363                "Parslee org lookup failed: HTTP 429 Too Many Requests: slow down",
13364                R::RateLimited,
13365            ),
13366            (
13367                "Parslee org lookup failed: HTTP 500 Internal Server Error: boom",
13368                R::Failed,
13369            ),
13370        ] {
13371            assert_eq!(
13372                classify_fallback_reason(&InferenceError::InferenceFailed(msg.into())),
13373                want,
13374                "{msg}"
13375            );
13376        }
13377    }
13378
13379    /// EVERY hop, in order — a chain that skips three lanes made three
13380    /// transitions, and a single first-wins slot records one while the journal
13381    /// downstream claims to hold them all.
13382    #[test]
13383    fn every_skipped_lane_is_recorded_in_order() {
13384        let mut hops = Vec::new();
13385        for (cand, err) in [
13386            (
13387                "lane-one",
13388                InferenceError::Transient {
13389                    status: Some(429),
13390                    message: "x".into(),
13391                },
13392            ),
13393            // Statusless transport: `Failed`, not `TimedOut` — the runtime
13394            // cannot tell a refused connection from a real deadline here.
13395            (
13396                "lane-two",
13397                InferenceError::Transient {
13398                    status: None,
13399                    message: "connection refused".into(),
13400                },
13401            ),
13402            (
13403                "lane-three",
13404                InferenceError::ProviderAccount {
13405                    provider: "openai".into(),
13406                    status: 401,
13407                    message: "rejected".into(),
13408                },
13409            ),
13410        ] {
13411            record_fallback_from(&mut hops, cand, &err);
13412        }
13413        assert_eq!(
13414            hops.iter()
13415                .map(|h| h.candidate.as_str())
13416                .collect::<Vec<_>>(),
13417            ["lane-one", "lane-two", "lane-three"]
13418        );
13419        assert_eq!(
13420            hops.iter().map(|h| h.reason).collect::<Vec<_>>(),
13421            [
13422                FallbackReason::RateLimited,
13423                FallbackReason::Failed,
13424                FallbackReason::CredentialRejected
13425            ]
13426        );
13427    }
13428
13429    /// `CredentialRejected` is BROADER than `auth_fallback_from`'s predicate,
13430    /// which is why the two are recorded independently rather than one being
13431    /// projected from the other.
13432    ///
13433    /// A provider refusing an API key is a rejected credential, and the journal
13434    /// should say so. It is NOT something `car auth login` fixes, so it must
13435    /// not drive the announcement that says to run it.
13436    #[test]
13437    fn a_refused_api_key_is_journaled_but_does_not_claim_sign_in_fixes_it() {
13438        let refused = InferenceError::ProviderAccount {
13439            provider: "openai".into(),
13440            status: 401,
13441            message: "provider rejected the API key — check the configured credential".into(),
13442        };
13443        let mut hops = Vec::new();
13444        record_fallback_from(&mut hops, "openai/gpt-5.6", &refused);
13445        assert_eq!(hops[0].reason, FallbackReason::CredentialRejected);
13446
13447        // The sign-in slot stays empty: this is not a lapsed session.
13448        let mut auth = None;
13449        record_auth_dead_lane(&mut auth, "openai/gpt-5.6", &refused.to_string());
13450        assert_eq!(auth, None, "car auth login does not fix a bad OpenAI key");
13451    }
13452
13453    #[test]
13454    fn auth_dead_lane_records_first_rejected_candidate_only() {
13455        // Nothing auth-failed → the field stays None, which is the common path.
13456        let mut slot: Option<String> = None;
13457        record_auth_dead_lane(
13458            &mut slot,
13459            "parslee/reasoning",
13460            "API returned 503: unavailable",
13461        );
13462        record_auth_dead_lane(&mut slot, "openai/gpt-5.6", "request timed out");
13463        assert_eq!(slot, None);
13464
13465        // An auth rejection names the lane...
13466        record_auth_dead_lane(
13467            &mut slot,
13468            "parslee/reasoning",
13469            "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required",
13470        );
13471        assert_eq!(slot.as_deref(), Some("parslee/reasoning"));
13472
13473        // ...and a LATER rejection does not overwrite it: the first one is the
13474        // lane the operator configured.
13475        record_auth_dead_lane(&mut slot, "anthropic/claude", "HTTP 403: forbidden");
13476        assert_eq!(slot.as_deref(), Some("parslee/reasoning"));
13477    }
13478
13479    #[test]
13480    fn configured_provider_with_expired_token_is_named_before_a_local_oom() {
13481        let expired = InferenceError::CredentialUnavailable {
13482            provider: "parslee".into(),
13483            model: "parslee/reasoning".into(),
13484            reason: CredentialFailure::Expired { expires_at: 42 },
13485            detail: "access token expired".into(),
13486        };
13487        let mut credential = None;
13488        record_route_credential_failure(&mut credential, "parslee/reasoning", &expired, false);
13489
13490        let error = apply_route_failure_context(
13491            InferenceError::InferenceFailed(
13492                "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
13493                    .into(),
13494            ),
13495            credential.as_ref(),
13496        )
13497        .to_string();
13498
13499        let auth_pos = error
13500            .find("Parslee login expired")
13501            .expect("expired login must be named");
13502        let remedy_pos = error
13503            .find("car auth login")
13504            .expect("credential remedy must be named");
13505        let oom_pos = error
13506            .find("9059 MB")
13507            .expect("fallback error must remain as secondary detail");
13508        assert!(auth_pos < remedy_pos && remedy_pos < oom_pos, "{error}");
13509        assert!(is_auth_failure_message(&error), "{error}");
13510    }
13511
13512    #[test]
13513    fn absent_login_is_named_before_a_local_oom() {
13514        let failure = parslee_signed_out_route_failure();
13515        let error = apply_route_failure_context(
13516            InferenceError::InferenceFailed("local fallback needs 9059 MB".into()),
13517            Some(&failure),
13518        )
13519        .to_string();
13520
13521        let absent_pos = error
13522            .find("Parslee login is absent")
13523            .expect("missing login must be named");
13524        let remedy_pos = error
13525            .find("car auth login")
13526            .expect("credential remedy must be named");
13527        let oom_pos = error
13528            .find("9059 MB")
13529            .expect("fallback OOM must remain as secondary detail");
13530        assert!(absent_pos < remedy_pos && remedy_pos < oom_pos, "{error}");
13531    }
13532
13533    #[test]
13534    fn genuine_local_oom_is_not_reclassified_as_auth() {
13535        let oom = InferenceError::InferenceFailed(
13536            "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
13537                .into(),
13538        );
13539        let error = apply_route_failure_context(oom, None);
13540        assert!(matches!(error, InferenceError::InferenceFailed(ref message)
13541            if message.starts_with("This model needs about 9059 MB")));
13542    }
13543
13544    #[test]
13545    fn unconfigured_provider_is_not_surfaced_over_the_terminal_failure() {
13546        let mut credential = None;
13547        record_route_credential_failure(
13548            &mut credential,
13549            "openai/gpt-5.6",
13550            &InferenceError::CredentialUnavailable {
13551                provider: "openai".into(),
13552                model: "openai/gpt-5.6".into(),
13553                reason: CredentialFailure::EnvVarMissing {
13554                    env_var: "OPENAI_API_KEY".into(),
13555                },
13556                detail: "set OPENAI_API_KEY".into(),
13557            },
13558            false,
13559        );
13560
13561        let terminal = InferenceError::InferenceFailed(
13562            "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
13563                .into(),
13564        );
13565        let error = apply_route_failure_context(terminal, credential.as_ref()).to_string();
13566        assert!(
13567            credential.is_none(),
13568            "an unconfigured fallback is ambient noise"
13569        );
13570        assert!(error.contains("9059 MB"), "{error}");
13571        assert!(!error.contains("OPENAI_API_KEY"), "{error}");
13572        assert!(!is_auth_failure_message(&error), "{error}");
13573    }
13574
13575    #[test]
13576    fn explicitly_requested_missing_credential_uses_the_shared_auth_table() {
13577        let mut credential = None;
13578        record_route_credential_failure(
13579            &mut credential,
13580            "openai/gpt-5.6",
13581            &InferenceError::CredentialUnavailable {
13582                provider: "openai".into(),
13583                model: "openai/gpt-5.6".into(),
13584                reason: CredentialFailure::EnvVarMissing {
13585                    env_var: "OPENAI_API_KEY".into(),
13586                },
13587                detail: "set OPENAI_API_KEY".into(),
13588            },
13589            true,
13590        );
13591        let summary = credential
13592            .expect("an explicitly requested provider must surface its missing key")
13593            .summary;
13594        assert!(summary.contains(AUTH_ENV_MISSING_MARKER), "{summary}");
13595        assert!(is_auth_failure_message(&summary), "{summary}");
13596    }
13597
13598    #[test]
13599    fn latest_actionable_credential_failure_wins() {
13600        let mut credential = None;
13601        record_route_credential_failure(
13602            &mut credential,
13603            "parslee/reasoning",
13604            &InferenceError::ProviderAccount {
13605                provider: "parslee".into(),
13606                status: 401,
13607                message: "Unauthorized".into(),
13608            },
13609            false,
13610        );
13611        record_route_credential_failure(
13612            &mut credential,
13613            "anthropic/claude",
13614            &InferenceError::CredentialUnavailable {
13615                provider: "anthropic".into(),
13616                model: "anthropic/claude".into(),
13617                reason: CredentialFailure::Expired { expires_at: 43 },
13618                detail: "configured token expired".into(),
13619            },
13620            false,
13621        );
13622
13623        assert_eq!(
13624            credential
13625                .expect("latest actionable credential cause must be retained")
13626                .summary,
13627            "anthropic login expired for `anthropic/claude` — run `car auth login`"
13628        );
13629    }
13630
13631    #[test]
13632    fn store_unreadable_summary_uses_the_shared_auth_table() {
13633        let summary = route_credential_failure(
13634            "parslee/reasoning",
13635            &InferenceError::CredentialUnavailable {
13636                provider: "parslee".into(),
13637                model: "parslee/reasoning".into(),
13638                reason: CredentialFailure::StoreUnreadable,
13639                detail: "keychain helper timed out".into(),
13640            },
13641            false,
13642        )
13643        .expect("an unreadable configured credential store is actionable");
13644        assert!(summary.contains(AUTH_STORE_UNREADABLE_MARKER), "{summary}");
13645        assert!(is_auth_failure_message(&summary), "{summary}");
13646    }
13647
13648    fn chain_gate_fixture_schema(id: &str, provider: &str, source: ModelSource) -> ModelSchema {
13649        ModelSchema {
13650            id: id.into(),
13651            name: id.into(),
13652            provider: provider.into(),
13653            family: "test".into(),
13654            version: "1".into(),
13655            capabilities: vec![ModelCapability::Generate],
13656            context_length: 32_768,
13657            max_output_tokens: Some(4_096),
13658            param_count: String::new(),
13659            quantization: None,
13660            performance: Default::default(),
13661            cost: Default::default(),
13662            source,
13663            tags: vec![],
13664            supported_params: vec![],
13665            public_benchmarks: vec![],
13666            trust_tier: TrustTier::Community,
13667            deprecated: false,
13668            available: true,
13669            weights_ready: true,
13670        }
13671    }
13672
13673    #[test]
13674    fn signed_out_pre_seed_is_gated_on_a_parslee_route_in_the_chain() {
13675        let local = chain_gate_fixture_schema(
13676            "qwen/qwen3-4b:q4_k_m",
13677            "qwen",
13678            ModelSource::Mlx {
13679                hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
13680                hf_weight_file: None,
13681            },
13682        );
13683        let ollama = chain_gate_fixture_schema(
13684            "ollama/llama3",
13685            "ollama",
13686            ModelSource::Ollama {
13687                model_tag: "llama3".into(),
13688                host: "http://localhost:11434".into(),
13689            },
13690        );
13691        let parslee = chain_gate_fixture_schema(
13692            "parslee/reasoning",
13693            "parslee",
13694            ModelSource::Proprietary {
13695                provider: "parslee".into(),
13696                endpoint: "https://api.parslee.ai".into(),
13697                auth: ProprietaryAuth::OAuth2Pkce {
13698                    authority: "https://login.example".into(),
13699                    client_id: "client".into(),
13700                    scopes: vec![],
13701                },
13702                protocol: Default::default(),
13703            },
13704        );
13705        let cloud = chain_gate_fixture_schema(
13706            "openai/gpt-5.6",
13707            "openai",
13708            ModelSource::RemoteApi {
13709                endpoint: "https://api.openai.com".into(),
13710                api_key_env: "OPENAI_API_KEY".into(),
13711                api_key_envs: vec![],
13712                api_version: None,
13713                protocol: schema::ApiProtocol::OpenAiCompat,
13714            },
13715        );
13716        let schemas: std::collections::HashMap<&str, &ModelSchema> = [
13717            ("qwen/qwen3-4b:q4_k_m", &local),
13718            ("ollama/llama3", &ollama),
13719            ("parslee/reasoning", &parslee),
13720            ("openai/gpt-5.6", &cloud),
13721        ]
13722        .into_iter()
13723        .collect();
13724        let resolve = |m: &str| schemas.get(m).copied();
13725
13726        // Local weights and local servers resolve no credential: no pre-seed.
13727        let local_only = vec![
13728            "qwen/qwen3-4b:q4_k_m".to_string(),
13729            "ollama/llama3".to_string(),
13730        ];
13731        assert!(!chain_includes_parslee_route(resolve, &local_only));
13732
13733        // Only a Parslee route keeps the Parslee-specific snapshot failure.
13734        let with_parslee = vec![
13735            "parslee/reasoning".to_string(),
13736            "qwen/qwen3-4b:q4_k_m".to_string(),
13737        ];
13738        assert!(chain_includes_parslee_route(resolve, &with_parslee));
13739        let with_cloud = vec![
13740            "openai/gpt-5.6".to_string(),
13741            "qwen/qwen3-4b:q4_k_m".to_string(),
13742        ];
13743        // An adaptive request with no explicit model can select a configured
13744        // preferred OpenAI route, then append a local fallback. Neither uses
13745        // the missing Parslee credential from the routing snapshot.
13746        assert!(!chain_includes_parslee_route(resolve, &with_cloud));
13747        let mut credential = chain_includes_parslee_route(resolve, &with_cloud)
13748            .then(parslee_signed_out_route_failure);
13749        let outage = InferenceError::Transient {
13750            status: Some(500),
13751            message: "OpenAI HTTP 500 Internal Server Error".into(),
13752        };
13753        record_route_credential_failure(&mut credential, &with_cloud[0], &outage, false);
13754        let terminal = apply_route_failure_context(outage, credential.as_ref());
13755        assert!(matches!(terminal, InferenceError::Transient { .. }));
13756        assert!(!is_auth_failure_message(&terminal.to_string()));
13757        let oom = InferenceError::InferenceFailed("local model out of memory".into());
13758        record_route_credential_failure(&mut credential, &with_cloud[1], &oom, false);
13759        let terminal = apply_route_failure_context(oom, credential.as_ref()).to_string();
13760        assert!(!terminal.contains("Parslee"), "{terminal}");
13761        assert!(!is_auth_failure_message(&terminal), "{terminal}");
13762
13763        // Discarding the unrelated pre-seed must not discard observed auth.
13764        record_route_credential_failure(
13765            &mut credential,
13766            &with_cloud[0],
13767            &InferenceError::ProviderAccount {
13768                provider: "openai".into(),
13769                status: 401,
13770                message: "invalid API key".into(),
13771            },
13772            false,
13773        );
13774        let terminal = apply_route_failure_context(
13775            InferenceError::InferenceFailed("local model out of memory".into()),
13776            credential.as_ref(),
13777        )
13778        .to_string();
13779        assert!(terminal.contains("openai"), "{terminal}");
13780        assert!(!terminal.contains("Parslee"), "{terminal}");
13781        assert!(is_auth_failure_message(&terminal), "{terminal}");
13782
13783        // An unknown candidate proves nothing and must not keep the pre-seed.
13784        let unknown = vec!["missing/model".to_string()];
13785        assert!(!chain_includes_parslee_route(resolve, &unknown));
13786    }
13787
13788    /// The four terminal failures from the review: with the pre-seed gated out
13789    /// of a local-only chain, none of them may render as a Parslee auth
13790    /// failure or classify as one.
13791    #[test]
13792    fn local_only_terminal_failures_are_not_relabeled_as_auth() {
13793        let cases = [
13794            // Local OOM.
13795            InferenceError::InferenceFailed(
13796                "This model needs about 9059 MB, beyond the configured 6553 MB local-model allocation"
13797                    .into(),
13798            ),
13799            // HTTP 500 from a local server.
13800            InferenceError::Transient {
13801                status: Some(500),
13802                message: "HTTP 500 Internal Server Error".into(),
13803            },
13804            // Crashed llama runner.
13805            InferenceError::InferenceFailed("llama runner process has terminated".into()),
13806        ];
13807        for case in cases {
13808            let error = apply_route_failure_context(case, None).to_string();
13809            assert!(!error.contains("Parslee login is absent"), "{error}");
13810            assert!(!is_auth_failure_message(&error), "{error}");
13811        }
13812
13813        // ModelNotFound keeps the pre-existing setup guidance (which names
13814        // `car auth login` as one of two legitimate remedies) but must not
13815        // open with a fabricated Parslee sign-out.
13816        let error = apply_route_failure_context(
13817            InferenceError::ModelNotFound("qwen/qwen3-4b:q4_k_m".into()),
13818            None,
13819        )
13820        .to_string();
13821        assert!(!error.contains("Parslee login is absent"), "{error}");
13822        assert!(error.contains("car models pull"), "{error}");
13823    }
13824
13825    /// The credential context wraps the terminal error without flattening its
13826    /// type — downstream retry/account logic branches on the variant.
13827    #[test]
13828    fn credential_context_preserves_the_typed_terminal_variant() {
13829        let credential = parslee_signed_out_route_failure();
13830
13831        let transient = apply_route_failure_context(
13832            InferenceError::Transient {
13833                status: Some(500),
13834                message: "HTTP 500 Internal Server Error".into(),
13835            },
13836            Some(&credential),
13837        );
13838        match &transient {
13839            InferenceError::Transient { status, message } => {
13840                assert_eq!(*status, Some(500));
13841                assert!(message.starts_with("Parslee login is absent"), "{message}");
13842                assert!(message.contains("HTTP 500"), "{message}");
13843            }
13844            other => panic!("Transient must stay Transient, got {other:?}"),
13845        }
13846
13847        let account = apply_route_failure_context(
13848            InferenceError::ProviderAccount {
13849                provider: "openai".into(),
13850                status: 402,
13851                message: "insufficient credits".into(),
13852            },
13853            Some(&credential),
13854        );
13855        assert!(
13856            matches!(
13857                &account,
13858                InferenceError::ProviderAccount {
13859                    provider,
13860                    status: 402,
13861                    ..
13862                } if provider == "openai"
13863            ),
13864            "ProviderAccount must stay ProviderAccount, got {account:?}"
13865        );
13866
13867        let unavailable = apply_route_failure_context(
13868            InferenceError::CredentialUnavailable {
13869                provider: "parslee".into(),
13870                model: "parslee/reasoning".into(),
13871                reason: CredentialFailure::Expired { expires_at: 42 },
13872                detail: "access token expired".into(),
13873            },
13874            Some(&credential),
13875        );
13876        match &unavailable {
13877            InferenceError::CredentialUnavailable { reason, detail, .. } => {
13878                assert_eq!(*reason, CredentialFailure::Expired { expires_at: 42 });
13879                assert!(detail.contains("Parslee login is absent"), "{detail}");
13880            }
13881            other => panic!("CredentialUnavailable must keep its reason data, got {other:?}"),
13882        }
13883        assert!(is_auth_failure_message(&unavailable.to_string()));
13884    }
13885
13886    /// The two non-Parslee summaries introduced by the route aggregation must
13887    /// classify through the one shared marker table.
13888    #[test]
13889    fn non_parslee_rejection_summaries_match_the_shared_classifier() {
13890        let rejected = route_credential_failure(
13891            "openai/gpt-5.6",
13892            &InferenceError::ProviderAccount {
13893                provider: "openai".into(),
13894                status: 403,
13895                message: "key revoked".into(),
13896            },
13897            false,
13898        )
13899        .expect("a 403 from a configured provider is actionable");
13900        assert!(rejected.contains("credential was rejected"), "{rejected}");
13901        assert!(is_auth_failure_message(&rejected), "{rejected}");
13902
13903        let generic = route_credential_failure(
13904            "openai/gpt-5.6",
13905            &InferenceError::InferenceFailed("upstream said: token expired".into()),
13906            false,
13907        )
13908        .expect("an auth-rejection message from a non-Parslee route is actionable");
13909        assert!(generic.contains("repair its provider login"), "{generic}");
13910        assert!(is_auth_failure_message(&generic), "{generic}");
13911    }
13912
13913    /// A signed-out fresh install exhausts with a no-backend error; the
13914    /// credential cause must not displace the `car models pull` setup path.
13915    #[test]
13916    fn fresh_install_exhaustion_keeps_the_models_pull_guidance() {
13917        let credential = parslee_signed_out_route_failure();
13918        let error = apply_route_failure_context(
13919            InferenceError::InferenceFailed("no models available for generate".into()),
13920            Some(&credential),
13921        )
13922        .to_string();
13923        assert!(error.contains("Parslee login is absent"), "{error}");
13924        assert!(
13925            error.contains("car models pull qwen/qwen3-4b:q4_k_m"),
13926            "{error}"
13927        );
13928        assert!(error.contains("car auth login"), "{error}");
13929    }
13930
13931    /// The field is absent from the wire on the common path (so no existing
13932    /// client sees a new key) and present when a lane was skipped.
13933    #[test]
13934    fn auth_fallback_from_round_trips_and_defaults_to_none() {
13935        let mut result: InferenceResult = serde_json::from_value(serde_json::json!({
13936            "text": "hi",
13937            "tool_calls": [],
13938            "trace_id": "t",
13939            "model_used": "openai/gpt-5.6",
13940            "latency_ms": 1,
13941        }))
13942        .expect("a payload without the field still deserializes");
13943        assert_eq!(result.auth_fallback_from, None);
13944        let json = serde_json::to_value(&result).unwrap();
13945        assert!(json.get("auth_fallback_from").is_none());
13946
13947        result.auth_fallback_from = Some("parslee/reasoning".to_string());
13948        let json = serde_json::to_value(&result).unwrap();
13949        assert_eq!(json["auth_fallback_from"], "parslee/reasoning");
13950        let back: InferenceResult = serde_json::from_value(json).unwrap();
13951        assert_eq!(
13952            back.auth_fallback_from.as_deref(),
13953            Some("parslee/reasoning")
13954        );
13955    }
13956
13957    /// A successful turn carries both the exact model that served it and an
13958    /// explicit marker when that model was the appended on-device last resort.
13959    /// The marker defaults false for older payloads, so this is additive on the
13960    /// wire rather than making old clients invent attribution.
13961    #[test]
13962    fn local_last_resort_turn_carries_model_id_and_flag() {
13963        let ordinary: InferenceResult = serde_json::from_value(serde_json::json!({
13964            "text": "hi",
13965            "tool_calls": [],
13966            "trace_id": "ordinary",
13967            "model_used": "anthropic/claude-haiku-4-5:latest",
13968            "latency_ms": 1,
13969        }))
13970        .expect("older payloads default the marker");
13971        assert!(!ordinary.local_last_resort);
13972
13973        let fallback: InferenceResult = serde_json::from_value(serde_json::json!({
13974            "text": "offline answer",
13975            "tool_calls": [],
13976            "trace_id": "fallback",
13977            "model_used": "Qwen3 4B MLX",
13978            "resolved_model_id": "mlx/qwen3-4b:4bit",
13979            "latency_ms": 1,
13980            "local_last_resort": true,
13981        }))
13982        .expect("fallback attribution payload");
13983        assert_eq!(fallback.served_model_id(), "mlx/qwen3-4b:4bit");
13984        assert!(fallback.local_last_resort);
13985
13986        assert!(!is_local_last_resort(None, "mlx/qwen3-4b:4bit"));
13987        assert!(report_local_last_resort_served(
13988            Some("mlx/qwen3-4b:4bit"),
13989            "mlx/qwen3-4b:4bit",
13990            "mlx/qwen3-4b:4bit"
13991        ));
13992        assert!(!is_local_last_resort(
13993            Some("mlx/qwen3-4b:4bit"),
13994            "anthropic/claude-haiku-4-5:latest"
13995        ));
13996    }
13997
13998    #[test]
13999    fn no_backend_hint_passes_through_transient_errors() {
14000        // Real failures on otherwise-configured models must NOT be
14001        // relabeled as "no backend / run setup" — they pass through.
14002        for phrase in [
14003            "API returned 401 Unauthorized",
14004            "API returned 429 Too Many Requests",
14005            "API returned 500 Internal Server Error",
14006            "connection refused",
14007            "request timed out",
14008            "parse response: unexpected end of input",
14009        ] {
14010            assert!(
14011                no_backend_recovery_hint(phrase).is_none(),
14012                "transient error wrongly classified as no-backend: {phrase:?}"
14013            );
14014        }
14015    }
14016
14017    /// Tests that mutate process-wide env vars must hold this lock to avoid
14018    /// races with parallel tests (env vars are global mutable state).
14019    static ENV_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
14020
14021    #[cfg(unix)]
14022    #[tokio::test]
14023    async fn exact_codex_subscription_row_dispatches_without_an_openai_key() {
14024        use std::os::unix::fs::PermissionsExt;
14025
14026        let _environment = ENV_MUTEX.lock().await;
14027        let _restore = RestoredEnvironment::capture(&["CAR_CODEX_BIN", "OPENAI_API_KEY"]);
14028        let tmp = TempDir::new().unwrap();
14029        let fixture = tmp.path().join("codex-fixture.sh");
14030        std::fs::write(
14031            &fixture,
14032            r#"#!/bin/sh
14033if [ -n "${OPENAI_API_KEY-}" ]; then
14034  echo 'OPENAI_API_KEY leaked' >&2
14035  exit 91
14036fi
14037cat >/dev/null
14038printf '%s\n' '{"type":"turn.started"}'
14039printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"fixture newsroom answer"}}'
14040printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":17,"output_tokens":5}}'
14041"#,
14042        )
14043        .unwrap();
14044        std::fs::set_permissions(&fixture, std::fs::Permissions::from_mode(0o700)).unwrap();
14045        unsafe {
14046            std::env::set_var("CAR_CODEX_BIN", &fixture);
14047            std::env::set_var("OPENAI_API_KEY", "must-not-reach-codex");
14048        }
14049
14050        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
14051        let mut request = GenerateRequest {
14052            prompt: "write a brief".into(),
14053            params: GenerateParams {
14054                max_tokens: 1056,
14055                strict_model: true,
14056                ..Default::default()
14057            },
14058            ..Default::default()
14059        };
14060        pin_exact_model_id(&mut request, "openai/gpt-5.6-sol:high".into()).unwrap();
14061        let result = engine.generate_tracked(request).await.unwrap();
14062
14063        assert_eq!(result.model_used, "openai/gpt-5.6-sol:high");
14064        assert_eq!(result.text, "fixture newsroom answer");
14065        assert_eq!(result.usage.as_ref().unwrap().total_tokens, 22);
14066        assert_eq!(result.stop_reason, None, "Codex reports no finish reason");
14067        assert!(result.tool_calls.is_empty());
14068    }
14069
14070    #[tokio::test]
14071    async fn isolated_test_child_removes_parent_auth_lock_override() {
14072        const CHILD_SENTINEL: &str = "CAR_AUTH_LOCK_SANITIZER_CHILD";
14073        if std::env::var_os(CHILD_SENTINEL).is_some() {
14074            assert!(
14075                std::env::var_os("CAR_AUTH_LOCK_PATH").is_none(),
14076                "an isolated credential test must not inherit the invoking process's auth lock"
14077            );
14078            return;
14079        }
14080
14081        let _environment = ENV_MUTEX.lock().await;
14082        let _restore = RestoredEnvironment::capture(&["CAR_AUTH_LOCK_PATH"]);
14083        unsafe {
14084            std::env::set_var(
14085                "CAR_AUTH_LOCK_PATH",
14086                "/sentinel/must-not-reach-isolated-child.lock",
14087            );
14088        }
14089        assert!(!crate::run_in_isolated_test_process(
14090            "tests::isolated_test_child_removes_parent_auth_lock_override",
14091            CHILD_SENTINEL,
14092        ));
14093    }
14094
14095    fn test_config(models_dir: PathBuf) -> InferenceConfig {
14096        // Keep every state file inside the test's own tree: the state root is
14097        // the models dir's parent, so `state_models_dir()` lands back on
14098        // `models_dir` exactly as it did before the root was split out.
14099        let state_root = models_dir
14100            .parent()
14101            .map(Path::to_path_buf)
14102            .unwrap_or_else(|| models_dir.clone());
14103        InferenceConfig {
14104            models_dir,
14105            state_root,
14106            device: None,
14107            generation_model: "Qwen3-0.6B".into(),
14108            preferred_generation_model: None,
14109            embedding_model: "Qwen3-Embedding-0.6B".into(),
14110            preferred_embedding_model: None,
14111            classification_model: "Qwen3-0.6B".into(),
14112            preferred_classification_model: None,
14113        }
14114    }
14115
14116    fn metal_mac_for_fit(ram_gb: u64) -> HardwareInfo {
14117        HardwareInfo {
14118            os: "macos".into(),
14119            arch: "aarch64".into(),
14120            cpu_cores: 8,
14121            total_ram_mb: ram_gb * 1024,
14122            gpu_backend: crate::hardware::GpuBackend::Metal,
14123            gpu_memory_mb: None,
14124            gpu_devices: vec![],
14125            recommended_model: String::new(),
14126            recommended_context: 8_192,
14127            max_model_mb: 0,
14128        }
14129    }
14130
14131    /// car#1399: the unified catalog is annotated for the machine it is
14132    /// asked about, without any row being dropped, reordered, or otherwise
14133    /// changed. Under Everyday (40% of unified memory) the builtin
14134    /// `mlx/qwen3-8b:4bit` (~6.3 GB at the recommendation context) is too
14135    /// big for an 8 GB Mac and fits a 32 GB one; a deprecated row stays
14136    /// listed and says so.
14137    #[test]
14138    fn unified_rows_carry_fit_per_machine_and_keep_deprecated_rows() {
14139        // Pin OpenRouter credential availability for BOTH snapshots below.
14140        // The remote-row set depends on whether a credential resolves, and
14141        // that resolution is process-global — a parallel test flipping the
14142        // override between the at_8 and at_32 listings made the two id sets
14143        // differ with nothing wrong in the catalog (car-sap7, and the trace
14144        // on the bead: availability differed between two snapshots).
14145        let _credential_scope = crate::openrouter::test_credential_scope();
14146        crate::openrouter::set_test_credential(Some("unified-rows-fit-test-key"));
14147        let root = tempfile::tempdir().unwrap();
14148        let config = test_config(root.path().join("weights"));
14149        let mut engine = InferenceEngine::new(config);
14150        let mut retired = engine
14151            .list_schemas()
14152            .into_iter()
14153            .find(|schema| schema.id == "mlx/qwen3-4b:4bit")
14154            .expect("builtin 4B MLX row");
14155        retired.id = "test/retired-4b:4bit".into();
14156        retired.name = "Retired 4B".into();
14157        retired.deprecated = true;
14158        engine.register_model(retired);
14159        let policy = resource_policy::ResourcePolicy::everyday();
14160
14161        let at_8 = engine.list_models_unified_for(&metal_mac_for_fit(8), &policy);
14162        let at_32 = engine.list_models_unified_for(&metal_mac_for_fit(32), &policy);
14163        let ids = |rows: &[ModelInfo]| rows.iter().map(|row| row.id.clone()).collect::<Vec<_>>();
14164        assert_eq!(
14165            ids(&at_8),
14166            ids(&at_32),
14167            "the machine never removes or reorders a row"
14168        );
14169        let row = |rows: &[ModelInfo], id: &str| {
14170            rows.iter()
14171                .find(|row| row.id == id)
14172                .unwrap_or_else(|| panic!("{id} missing"))
14173                .clone()
14174        };
14175
14176        let eight_b_small = row(&at_8, "mlx/qwen3-8b:4bit");
14177        let eight_b_large = row(&at_32, "mlx/qwen3-8b:4bit");
14178        assert_eq!(eight_b_small.fit, ModelFitStatus::TooBig);
14179        assert_eq!(eight_b_large.fit, ModelFitStatus::Fits);
14180        assert!(eight_b_small.platform_compatible && eight_b_large.platform_compatible);
14181        assert_eq!(
14182            eight_b_small.estimated_peak_mb, eight_b_large.estimated_peak_mb,
14183            "the estimate is the model's; only the budget differs"
14184        );
14185        assert!(eight_b_small.estimated_peak_mb.is_some_and(|mb| mb > 4_800));
14186        assert_eq!(eight_b_small.family.as_deref(), Some("qwen3"));
14187        assert!(eight_b_small.version.is_some());
14188
14189        let retired = row(&at_8, "test/retired-4b:4bit");
14190        assert!(retired.deprecated, "deprecated rows stay listed, flagged");
14191        assert!(!eight_b_small.deprecated);
14192
14193        let mut saw_remote = false;
14194        for remote in at_8.iter().filter(|row| !row.is_local) {
14195            saw_remote = true;
14196            assert_eq!(remote.fit, ModelFitStatus::Fits, "{}", remote.id);
14197            assert!(remote.platform_compatible, "{}", remote.id);
14198            assert_eq!(remote.estimated_peak_mb, None, "{}", remote.id);
14199            assert_eq!(
14200                remote.family, None,
14201                "{}: no upstream identifier here",
14202                remote.id
14203            );
14204            assert_eq!(remote.version, None, "{}", remote.id);
14205        }
14206        assert!(saw_remote);
14207
14208        // Every existing field is byte-identical across machines: strip the
14209        // three fit keys and the rows must serialize the same.
14210        for (small, large) in at_8.iter().zip(&at_32) {
14211            let strip = |row: &ModelInfo| {
14212                let mut value = serde_json::to_value(row).unwrap();
14213                let object = value.as_object_mut().unwrap();
14214                for key in ["fit", "estimated_peak_mb", "platform_compatible"] {
14215                    object.remove(key);
14216                }
14217                value
14218            };
14219            assert_eq!(strip(small), strip(large), "{}", small.id);
14220        }
14221    }
14222
14223    #[tokio::test]
14224    async fn explicit_model_blocked_by_zero_budget_does_not_substitute_or_download() {
14225        let root = tempfile::tempdir().unwrap();
14226        let models_dir = root.path().join("weights");
14227        let config = test_config(models_dir.clone());
14228        let repository =
14229            crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
14230        crate::resource_policy::ResourcePolicyRepository::save(
14231            &repository,
14232            &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
14233        )
14234        .unwrap();
14235        let engine = InferenceEngine::new(config);
14236
14237        let error = engine
14238            .generate_tracked(GenerateRequest {
14239                prompt: "hello".into(),
14240                model: Some("mlx/qwen3-4b:4bit".into()),
14241                ..Default::default()
14242            })
14243            .await
14244            .unwrap_err();
14245
14246        assert!(matches!(
14247            error,
14248            InferenceError::LocalResourceBlocked {
14249                preflight: crate::resource_policy::LocalLoadPreflight {
14250                    verdict: crate::resource_policy::LocalLoadVerdict::DisabledByPolicy,
14251                    ..
14252                },
14253                ..
14254            }
14255        ));
14256        assert!(
14257            !models_dir.exists() || std::fs::read_dir(models_dir).unwrap().next().is_none(),
14258            "admission must happen before download/load"
14259        );
14260    }
14261
14262    #[tokio::test]
14263    async fn external_vllm_mlx_bypasses_local_admission_while_managed_artifact_does_not() {
14264        let schema = crate::vllm_mlx::to_model_schema(
14265            &crate::vllm_mlx::DiscoveredModel {
14266                id: "mlx-community/Qwen3-4B-4bit".into(),
14267                owned_by: None,
14268            },
14269            "http://localhost:8000",
14270        );
14271        assert!(!InferenceEngine::requires_local_admission(&schema));
14272
14273        let root = tempfile::tempdir().unwrap();
14274        let config = test_config(root.path().join("weights"));
14275        let repository =
14276            crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
14277        crate::resource_policy::ResourcePolicyRepository::save(
14278            &repository,
14279            &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
14280        )
14281        .unwrap();
14282        let engine = InferenceEngine::new(config);
14283        let (external, reservation) = engine
14284            .vllm_live_schema(schema.clone(), None, 0)
14285            .await
14286            .unwrap();
14287        assert!(matches!(
14288            external.source,
14289            ModelSource::VllmMlx { ref endpoint, .. } if endpoint == "http://localhost:8000"
14290        ));
14291        assert!(reservation.is_none());
14292
14293        let mut managed = schema;
14294        managed.source = ModelSource::ManagedVllmMlx {
14295            hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
14296            hf_weight_file: None,
14297        };
14298        assert!(InferenceEngine::requires_local_admission(&managed));
14299        let error = engine
14300            .reserve_local_request(&managed, 0)
14301            .expect_err("zero-GB policy must reject before vllm runtime/download/spawn");
14302        assert!(matches!(
14303            error,
14304            InferenceError::LocalResourceBlocked {
14305                preflight: crate::resource_policy::LocalLoadPreflight {
14306                    verdict: crate::resource_policy::LocalLoadVerdict::DisabledByPolicy,
14307                    ..
14308                },
14309                ..
14310            }
14311        ));
14312    }
14313
14314    #[cfg(unix)]
14315    async fn assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(
14316        streaming: bool,
14317        cancel_first: bool,
14318    ) {
14319        struct FixedProbe;
14320        impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
14321            fn available_memory_mb(
14322                &self,
14323            ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
14324                Ok(Some(24_000))
14325            }
14326        }
14327
14328        let Some(python) = crate::vllm_runtime::test_python_interpreter() else {
14329            panic!("a real Python interpreter is required for the managed-vllm dispatch fixture");
14330        };
14331        let root = tempfile::tempdir().unwrap();
14332        let script = root.path().join("fake-vllm-mlx");
14333        let spawned = root.path().join("spawned");
14334        let release = root.path().join("release");
14335        std::fs::write(
14336            &script,
14337            format!(
14338                "#!{}\n\
14339                 import http.server, os, sys, time\n\
14340                 marker = {:?}\n\
14341                 release = {:?}\n\
14342                 open(marker, 'w').close()\n\
14343                 while not os.path.exists(release): time.sleep(0.01)\n\
14344                 port = int(sys.argv[sys.argv.index('--port') + 1])\n\
14345                 class H(http.server.BaseHTTPRequestHandler):\n\
14346                 \x20   def do_GET(self):\n\
14347                 \x20       self.send_response(200); self.end_headers(); self.wfile.write(b'ok')\n\
14348                 \x20   def do_POST(self):\n\
14349                 \x20       length = int(self.headers.get('content-length', '0'))\n\
14350                 \x20       request = self.rfile.read(length).replace(b' ', b'')\n\
14351                 \x20       if b'\"stream\":true' in request:\n\
14352                 \x20           body = b'data: {{\"id\":\"fixture\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"fixture\",\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"round13-stream\"}},\"finish_reason\":null}}]}}\\n\\ndata: {{\"id\":\"fixture\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"fixture\",\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}]}}\\n\\ndata: [DONE]\\n\\n'\n\
14353                 \x20           content_type = 'text/event-stream'\n\
14354                 \x20       else:\n\
14355                 \x20           body = b'{{\"id\":\"fixture\",\"object\":\"chat.completion\",\"created\":0,\"model\":\"fixture\",\"choices\":[{{\"index\":0,\"message\":{{\"role\":\"assistant\",\"content\":\"round13-ok\"}},\"finish_reason\":\"stop\"}}],\"usage\":{{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}}}'\n\
14356                 \x20           content_type = 'application/json'\n\
14357                 \x20       self.send_response(200); self.send_header('content-type', content_type); self.send_header('content-length', str(len(body))); self.end_headers(); self.wfile.write(body)\n\
14358                 \x20   def log_message(self, *args): pass\n\
14359                 http.server.HTTPServer(('127.0.0.1', port), H).serve_forever()\n",
14360                python.display(),
14361                spawned.display().to_string(),
14362                release.display().to_string(),
14363            ),
14364        )
14365        .unwrap();
14366        use std::os::unix::fs::PermissionsExt;
14367        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
14368
14369        let models_dir = root.path().join("models");
14370        let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
14371        let mut schema = crate::registry::builtin_catalog()
14372            .into_iter()
14373            .find(ModelSchema::is_car_managed_vllm_mlx)
14374            .expect("managed vllm fixture schema");
14375        schema.id = "vllm-mlx/round12-singleflight".into();
14376        schema.name = "round12-singleflight".into();
14377        schema.cost.size_mb = Some(1);
14378        schema.cost.ram_mb = Some(1);
14379        schema.source = ModelSource::ManagedVllmMlx {
14380            hf_repo: "fixture/round12-singleflight".into(),
14381            hf_weight_file: None,
14382        };
14383        let model_dir = models_dir.join(&schema.name);
14384        std::fs::create_dir_all(&model_dir).unwrap();
14385        std::fs::write(model_dir.join("config.json"), b"{}").unwrap();
14386        std::fs::write(model_dir.join("model.safetensors"), b"fixture").unwrap();
14387        engine
14388            .unified_registry
14389            .register_project_model(schema.clone());
14390
14391        let coordinator = Arc::new(
14392            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
14393                // Two concurrent Linux requests each carry 1 GiB of runtime
14394                // overhead plus context/transient headroom. Keep the fixture
14395                // above that platform-specific total so this test exercises
14396                // dispatch singleflight rather than an unrelated ceiling.
14397                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
14398                crate::hardware::HardwareInfo::detect(),
14399                Arc::new(FixedProbe),
14400            ),
14401        );
14402        engine.local_admission = coordinator.clone();
14403        engine.vllm_pool = Arc::new(crate::vllm_pool::VllmServerPool::with_test_runtime(
14404            std::time::Duration::from_secs(300),
14405            coordinator,
14406            script,
14407        ));
14408        let engine = Arc::new(engine);
14409        let model_id = schema.id.clone();
14410        let mut request = GenerateRequest {
14411            prompt: "hello from round 13".into(),
14412            model: Some(model_id.clone()),
14413            ..Default::default()
14414        };
14415        // A fixture failure must stay a fixture failure. Without strict model
14416        // routing, the streaming case escaped to a configured provider and
14417        // returned real, nondeterministic prose instead of exercising this
14418        // managed-vLLM singleflight path.
14419        request.params.strict_model = true;
14420        let (estimated_input, _, _) = engine.estimated_tokens(&request, Some(&model_id));
14421        let expected_footprint = estimated_input.saturating_add(request.params.max_tokens);
14422        let expected_estimate = engine
14423            .local_model_preflight(&model_id, expected_footprint)
14424            .unwrap()
14425            .estimate;
14426        let expected_request_overhead_mb = expected_estimate
14427            .runtime_overhead_mb
14428            .saturating_add(expected_estimate.context_overhead_mb)
14429            .saturating_add(expected_estimate.transient_margin_mb);
14430
14431        async fn execute(
14432            engine: Arc<InferenceEngine>,
14433            request: GenerateRequest,
14434            streaming: bool,
14435            direct_vllm: bool,
14436        ) -> Result<String, InferenceError> {
14437            if direct_vllm {
14438                let model_id = request.model.as_deref().expect("explicit fixture model");
14439                let schema = engine
14440                    .unified_registry
14441                    .get(model_id)
14442                    .cloned()
14443                    .expect("fixture schema");
14444                let (schema, _) = engine.vllm_live_schema(schema, None, 0).await?;
14445                return match schema.source {
14446                    ModelSource::VllmMlx { endpoint, .. } => Ok(endpoint),
14447                    source => Err(InferenceError::InferenceFailed(format!(
14448                        "fixture did not resolve to a vllm endpoint: {source:?}"
14449                    ))),
14450                };
14451            }
14452            if !streaming {
14453                return engine
14454                    .generate_tracked(request)
14455                    .await
14456                    .map(|result| result.text);
14457            }
14458            let mut tracked = engine.generate_tracked_stream(request).await?;
14459            let mut accumulator = crate::stream::StreamAccumulator::default();
14460            while let Some(event) = tracked.events.recv().await {
14461                let done = matches!(event, crate::stream::StreamEvent::Done { .. });
14462                accumulator.push(&event);
14463                if done {
14464                    break;
14465                }
14466            }
14467            Ok(accumulator.finish().0)
14468        }
14469
14470        let first_engine = engine.clone();
14471        let first_request = request.clone();
14472        let mut first = tokio::spawn(async move {
14473            execute(first_engine, first_request, streaming, cancel_first).await
14474        });
14475        tokio::time::timeout(std::time::Duration::from_secs(5), async {
14476            while !spawned.exists() {
14477                if first.is_finished() {
14478                    let result = (&mut first).await;
14479                    panic!("first dispatch ended before spawn: {result:?}");
14480                }
14481                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
14482            }
14483        })
14484        .await
14485        .expect("first request must spawn before publication");
14486        if !cancel_first {
14487            let during_startup = engine.local_model_preflight(&model_id, 0).unwrap();
14488            assert_eq!(
14489                during_startup.active_reservations_mb, expected_request_overhead_mb,
14490                "the cold-to-pending transfer must retain all request context/KV overhead"
14491            );
14492            assert_eq!(
14493                during_startup.resident_model_mb, expected_estimate.weights_mb,
14494                "the pending allocation must replace the exact cold-weight charge"
14495            );
14496            assert_eq!(
14497                during_startup
14498                    .active_reservations_mb
14499                    .saturating_add(during_startup.resident_model_mb),
14500                expected_estimate.estimated_peak_mb,
14501                "active request overhead plus pending weights must preserve the full admitted footprint"
14502            );
14503        }
14504
14505        let second_engine = engine.clone();
14506        let mut second =
14507            tokio::spawn(
14508                async move { execute(second_engine, request, streaming, cancel_first).await },
14509            );
14510        assert!(
14511            tokio::time::timeout(std::time::Duration::from_millis(100), &mut second)
14512                .await
14513                .is_err(),
14514            "request 2 must wait for request 1 to publish instead of failing on startup state"
14515        );
14516
14517        let first_result = if cancel_first {
14518            std::fs::remove_file(&spawned).unwrap();
14519            first.abort();
14520            let _ = first.await;
14521            tokio::time::timeout(std::time::Duration::from_secs(5), async {
14522                while !spawned.exists() {
14523                    if second.is_finished() {
14524                        let result = (&mut second).await;
14525                        panic!("waiter ended before replacement spawn: {result:?}");
14526                    }
14527                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
14528                }
14529            })
14530            .await
14531            .expect("waiter must continue after cancelled owner teardown");
14532            None
14533        } else {
14534            std::fs::write(&release, b"release").unwrap();
14535            Some(
14536                tokio::time::timeout(std::time::Duration::from_secs(5), first)
14537                    .await
14538                    .expect("first dispatch completes")
14539                    .unwrap()
14540                    .unwrap(),
14541            )
14542        };
14543        std::fs::write(&release, b"release").unwrap();
14544        let second_result = tokio::time::timeout(std::time::Duration::from_secs(5), second)
14545            .await
14546            .expect("second dispatch completes")
14547            .unwrap()
14548            .unwrap();
14549        let expected = if cancel_first {
14550            None
14551        } else if streaming {
14552            Some("round13-stream")
14553        } else {
14554            Some("round13-ok")
14555        };
14556        if let (Some(first_result), Some(expected)) = (first_result, expected) {
14557            assert_eq!(first_result, expected);
14558        }
14559        if let Some(expected) = expected {
14560            assert_eq!(second_result, expected);
14561        } else {
14562            assert!(second_result.starts_with("http://127.0.0.1:"));
14563        }
14564        assert_eq!(engine.vllm_pool.len().await, 1);
14565        assert!(engine
14566            .vllm_pool
14567            .release_model_if_present(&model_id)
14568            .await
14569            .unwrap());
14570    }
14571
14572    #[cfg(unix)]
14573    #[tokio::test]
14574    async fn concurrent_managed_vllm_generate_waits_before_outer_reservation() {
14575        assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(false, false).await;
14576    }
14577
14578    #[cfg(unix)]
14579    #[tokio::test]
14580    async fn concurrent_managed_vllm_stream_waits_before_outer_reservation() {
14581        assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(true, false).await;
14582    }
14583
14584    #[cfg(unix)]
14585    #[tokio::test]
14586    async fn managed_vllm_waiter_continues_after_startup_owner_cancellation() {
14587        assert_concurrent_managed_vllm_dispatch_waits_before_outer_reservation(false, true).await;
14588    }
14589
14590    #[tokio::test]
14591    async fn same_state_root_engines_share_runtime_components() {
14592        const CHILD_ENV: &str = "CAR_TWO_ENGINE_RUNTIME_TEST_CHILD";
14593        if std::env::var_os(CHILD_ENV).is_some() {
14594            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
14595            return;
14596        }
14597        let root = tempfile::tempdir().unwrap();
14598        let first = InferenceEngine::new(test_config(root.path().join("weights")));
14599        let second = InferenceEngine::new(test_config(root.path().join("weights")));
14600
14601        assert!(Arc::ptr_eq(&first.model_budget, &second.model_budget));
14602        assert!(Arc::ptr_eq(&first.vllm_pool, &second.vllm_pool));
14603        assert!(Arc::ptr_eq(
14604            &first.resource_policy_generation,
14605            &second.resource_policy_generation
14606        ));
14607        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
14608        {
14609            assert!(Arc::ptr_eq(&first.mlx_backends, &second.mlx_backends));
14610            assert!(Arc::ptr_eq(&first.local_backends, &second.local_backends));
14611            assert!(Arc::ptr_eq(&first.flux_cache, &second.flux_cache));
14612            assert!(Arc::ptr_eq(&first.ltx_cache, &second.ltx_cache));
14613            assert!(Arc::ptr_eq(&first.kokoro_cache, &second.kokoro_cache));
14614        }
14615        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
14616        {
14617            assert!(Arc::ptr_eq(&first.backend, &second.backend));
14618            assert!(Arc::ptr_eq(
14619                &first.embedding_backend,
14620                &second.embedding_backend
14621            ));
14622        }
14623
14624        let loads = Arc::new(std::sync::atomic::AtomicU64::new(0));
14625        let first_cache = first._runtime_scope.load_probe.clone();
14626        let second_cache = second._runtime_scope.load_probe.clone();
14627        let mut threads = Vec::new();
14628        for cache in [first_cache, second_cache] {
14629            let loads = loads.clone();
14630            threads.push(std::thread::spawn(move || {
14631                cache
14632                    .get_or_load::<()>("same/model", 1, || {
14633                        loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14634                        std::thread::sleep(std::time::Duration::from_millis(20));
14635                        Ok(())
14636                    })
14637                    .unwrap()
14638            }));
14639        }
14640        let handles = threads
14641            .into_iter()
14642            .map(|thread| thread.join().unwrap())
14643            .collect::<Vec<_>>();
14644        assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
14645        assert!(Arc::ptr_eq(&handles[0], &handles[1]));
14646
14647        let child = tokio::process::Command::new(std::env::current_exe().unwrap())
14648            .arg("--exact")
14649            .arg("tests::same_state_root_engines_share_runtime_components")
14650            .env(CHILD_ENV, "1")
14651            .stdin(std::process::Stdio::null())
14652            .stdout(std::process::Stdio::null())
14653            .stderr(std::process::Stdio::null())
14654            .kill_on_drop(true)
14655            .spawn()
14656            .unwrap();
14657        first
14658            .vllm_pool
14659            .insert_test_process("vllm-mlx/two-engine", child)
14660            .await;
14661        first.local_admission.mark_resident_allocation(
14662            "vllm-mlx/two-engine",
14663            &resource_policy::vllm_process_allocation_id("vllm-mlx/two-engine"),
14664            1,
14665        );
14666        assert!(second.vllm_pool.contains("vllm-mlx/two-engine").await);
14667        drop(first);
14668        assert!(second.vllm_pool.contains("vllm-mlx/two-engine").await);
14669        assert!(second.local_admission.is_resident("vllm-mlx/two-engine"));
14670        assert!(second.vllm_pool.evict_model("vllm-mlx/two-engine").await);
14671        assert!(!second.local_admission.is_resident("vllm-mlx/two-engine"));
14672    }
14673
14674    #[tokio::test]
14675    async fn last_engine_drop_reaps_vllm_before_new_runtime_admission() {
14676        const CHILD_ENV: &str = "CAR_LAST_ENGINE_VLLM_DROP_TEST_CHILD";
14677        if std::env::var_os(CHILD_ENV).is_some() {
14678            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
14679            return;
14680        }
14681        let root = tempfile::tempdir().unwrap();
14682        let config = test_config(root.path().join("weights"));
14683        let engine = InferenceEngine::new(config.clone());
14684        let coordinator = engine.local_admission.clone();
14685        let schema = engine
14686            .unified_registry
14687            .all()
14688            .find(|schema| schema.is_vllm_mlx())
14689            .cloned()
14690            .expect("supervised vllm schema");
14691        let child = tokio::process::Command::new(std::env::current_exe().unwrap())
14692            .arg("--exact")
14693            .arg("tests::last_engine_drop_reaps_vllm_before_new_runtime_admission")
14694            .env(CHILD_ENV, "1")
14695            .stdin(std::process::Stdio::null())
14696            .stdout(std::process::Stdio::null())
14697            .stderr(std::process::Stdio::null())
14698            .kill_on_drop(true)
14699            .spawn()
14700            .unwrap();
14701        engine
14702            .vllm_pool
14703            .insert_test_process(&schema.id, child)
14704            .await;
14705        coordinator.mark_resident_allocation(
14706            &schema.id,
14707            &resource_policy::vllm_process_allocation_id(&schema.id),
14708            1,
14709        );
14710
14711        drop(engine);
14712
14713        tokio::time::timeout(std::time::Duration::from_secs(2), async {
14714            while coordinator.teardown_pending(&schema.id) {
14715                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
14716            }
14717        })
14718        .await
14719        .expect("last runtime drop must eventually confirm supervised child exit");
14720        assert!(!coordinator.is_resident(&schema.id));
14721        let next = InferenceEngine::new(config);
14722        assert!(Arc::ptr_eq(&coordinator, &next.local_admission));
14723        let preflight = next.local_model_preflight(&schema.id, 0).unwrap();
14724        assert_eq!(preflight.resident_model_mb, 0);
14725        assert!(preflight.estimated_incremental_mb >= preflight.estimate.weights_mb);
14726    }
14727
14728    #[cfg(unix)]
14729    #[test]
14730    fn symlinked_state_roots_share_exact_coordinator_and_runtime() {
14731        use std::os::unix::fs::symlink;
14732
14733        let fixture = tempfile::tempdir().unwrap();
14734        let real = fixture.path().join("real-state");
14735        std::fs::create_dir(&real).unwrap();
14736        let alias = fixture.path().join("state-alias");
14737        symlink(&real, &alias).unwrap();
14738
14739        let first = InferenceEngine::new(test_config(real.join("weights")));
14740        let second = InferenceEngine::new(test_config(alias.join("weights")));
14741        assert!(Arc::ptr_eq(&first.local_admission, &second.local_admission));
14742        assert!(Arc::ptr_eq(&first._runtime_scope, &second._runtime_scope));
14743        assert!(Arc::ptr_eq(&first.model_budget, &second.model_budget));
14744    }
14745
14746    #[tokio::test]
14747    async fn stream_reservation_lives_until_returned_receiver_is_released() {
14748        let root = tempfile::tempdir().unwrap();
14749        let engine = InferenceEngine::new(test_config(root.path().join("weights")));
14750        let schema = crate::registry::builtin_catalog()
14751            .into_iter()
14752            .find(|schema| schema.is_local() && !schema.is_vllm_mlx())
14753            .expect("local model schema");
14754        engine.local_admission.mark_resident(&schema.id, 1);
14755        let reservation = engine.reserve_local_request(&schema, 64).unwrap();
14756        assert_eq!(engine.local_admission.active_request_count(&schema.id), 1);
14757        let (source_tx, source_rx) = tokio::sync::mpsc::channel(1);
14758        let returned =
14759            InferenceEngine::hold_optional_reservation_for_stream(source_rx, Some(reservation));
14760
14761        drop(returned);
14762        source_tx
14763            .send(stream::StreamEvent::TextDelta("release".into()))
14764            .await
14765            .unwrap();
14766        for _ in 0..20 {
14767            if engine.local_admission.active_request_count(&schema.id) == 0 {
14768                break;
14769            }
14770            tokio::task::yield_now().await;
14771        }
14772        assert_eq!(engine.local_admission.active_request_count(&schema.id), 0);
14773
14774        let compact_source = include_str!("lib.rs")
14775            .split_whitespace()
14776            .collect::<String>();
14777        let remote_handoff = [
14778            "Self::hold_optional_reservation_for_stream",
14779            "(receiver,",
14780            "candidate_reservation.take(),",
14781            ");",
14782        ]
14783        .concat();
14784        assert!(compact_source.contains(&remote_handoff));
14785    }
14786
14787    #[tokio::test]
14788    async fn residual_voice_allocation_blocks_removal_with_typed_error() {
14789        let root = tempfile::tempdir().unwrap();
14790        let engine = InferenceEngine::new(test_config(root.path().join("weights")));
14791        engine
14792            .local_admission
14793            .mark_resident("voice/removal-fixture", 1);
14794
14795        let error = match engine
14796            .prepare_local_model_removal("voice/removal-fixture")
14797            .await
14798        {
14799            Err(error) => error,
14800            Ok(_) => panic!("voice allocation must be released by its owner first"),
14801        };
14802        assert!(matches!(
14803            error,
14804            crate::resource_policy::ModelMaintenanceError::ResidualResidency {
14805                model_id,
14806                allocation_ids,
14807            } if model_id == "voice/removal-fixture"
14808                && allocation_ids == vec!["voice/removal-fixture"]
14809        ));
14810    }
14811
14812    #[tokio::test]
14813    async fn catalog_voice_id_blocks_removal_of_live_provider_alias() {
14814        let root = tempfile::tempdir().unwrap();
14815        let engine = InferenceEngine::new(test_config(root.path().join("weights")));
14816        engine.local_admission.register_model_aliases(
14817            "voice/parakeet-tdt-0.6b",
14818            ["mlx/parakeet-tdt-0.6b-v3:default"],
14819        );
14820        let mut reservation = engine
14821            .local_admission
14822            .reserve_measured_host_allocation(
14823                "voice/parakeet-tdt-0.6b",
14824                "voice/parakeet-tdt-0.6b#voice-allocation-0",
14825                1024 * 1024,
14826                0,
14827            )
14828            .unwrap();
14829        reservation.publish_resident_weights(1024 * 1024);
14830        drop(reservation);
14831
14832        let result = engine
14833            .prepare_local_model_removal("mlx/parakeet-tdt-0.6b-v3:default")
14834            .await;
14835        assert!(matches!(
14836            result,
14837            Err(crate::resource_policy::ModelMaintenanceError::ResidualResidency { .. })
14838        ));
14839    }
14840
14841    struct RefusingRemovalOffload;
14842
14843    #[async_trait::async_trait]
14844    impl crate::offload::LocalGenerationOffload for RefusingRemovalOffload {
14845        async fn generate(
14846            &self,
14847            _request: GenerateRequest,
14848        ) -> Result<InferenceResult, InferenceError> {
14849            unreachable!("removal test does not generate")
14850        }
14851
14852        async fn stream(
14853            &self,
14854            _request: GenerateRequest,
14855        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
14856            unreachable!("removal test does not stream")
14857        }
14858
14859        async fn resident_models(&self) -> Vec<String> {
14860            vec!["worker/refuses-release".into()]
14861        }
14862
14863        async fn release_model(&self, _model_id: &str) -> Result<bool, InferenceError> {
14864            Ok(false)
14865        }
14866    }
14867
14868    #[tokio::test]
14869    async fn worker_release_boolean_is_required_for_model_removal() {
14870        let _offload_guard = crate::offload::test_offload_lock().lock().await;
14871        crate::offload::set_local_offload(Some(Arc::new(RefusingRemovalOffload)));
14872        let root = tempfile::tempdir().unwrap();
14873        let engine = InferenceEngine::new(test_config(root.path().join("weights")));
14874        let result = engine
14875            .prepare_local_model_removal("worker/refuses-release")
14876            .await;
14877        crate::offload::set_local_offload(None);
14878
14879        assert!(matches!(
14880            result,
14881            Err(crate::resource_policy::ModelMaintenanceError::WorkerReleaseUnacknowledged(
14882                model_id
14883            )) if model_id == "worker/refuses-release"
14884        ));
14885    }
14886
14887    #[test]
14888    fn transient_mlx_vlm_allocation_never_becomes_resident() {
14889        struct FixedProbe;
14890        impl crate::resource_policy::LiveMemoryProbe for FixedProbe {
14891            fn available_memory_mb(
14892                &self,
14893            ) -> Result<Option<u64>, crate::resource_policy::ResourcePolicyError> {
14894                Ok(Some(24_000))
14895            }
14896        }
14897
14898        let root = tempfile::tempdir().unwrap();
14899        let mut engine = InferenceEngine::new(test_config(root.path().join("weights")));
14900        engine.local_admission = Arc::new(
14901            crate::resource_policy::LocalAdmissionCoordinator::with_probe(
14902                crate::resource_policy::ResourcePolicy::custom_gb(8.0).unwrap(),
14903                crate::hardware::HardwareInfo {
14904                    total_ram_mb: 32 * 1024,
14905                    ..crate::hardware::HardwareInfo::detect()
14906                },
14907                Arc::new(FixedProbe),
14908            ),
14909        );
14910        let mut schema = crate::registry::builtin_catalog()
14911            .into_iter()
14912            .find(|schema| schema.tags.iter().any(|tag| tag == "mlx-vlm-cli"))
14913            .expect("one-shot mlx-vlm schema");
14914        schema.param_count = "1M".into();
14915        schema.quantization = Some(Quantization::parse("Q4"));
14916        let mut reservation = engine.reserve_local_request(&schema, 64).unwrap();
14917        InferenceEngine::reconcile_transient_local_allocation(&mut reservation, 1024 * 1024)
14918            .unwrap();
14919        drop(reservation);
14920        assert!(!engine.local_admission.is_resident(&schema.id));
14921    }
14922
14923    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
14924    #[test]
14925    fn non_apple_backend_map_keeps_two_local_model_identities_exact() {
14926        let mut backends = std::collections::HashMap::<String, u32>::new();
14927        backends.insert("local/model-a".into(), 1);
14928        backends.insert("local/model-b".into(), 2);
14929        assert_eq!(backends.get("local/model-a"), Some(&1));
14930        assert_eq!(backends.get("local/model-b"), Some(&2));
14931        assert_eq!(backends.remove("local/model-a"), Some(1));
14932        assert!(!backends.contains_key("local/model-a"));
14933        assert_eq!(backends.get("local/model-b"), Some(&2));
14934    }
14935
14936    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
14937    #[tokio::test]
14938    async fn non_apple_remote_classification_at_zero_gb_routes_without_local_allocation() {
14939        use wiremock::matchers::{method, path};
14940        use wiremock::{Mock, MockServer, ResponseTemplate};
14941
14942        let _credential_scope = crate::openrouter::test_credential_scope();
14943        crate::openrouter::set_test_credential(Some("test-openrouter-key"));
14944        let server = MockServer::start().await;
14945        Mock::given(method("POST"))
14946            .and(path("/v1/chat/completions"))
14947            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
14948                "choices": [{
14949                    "message": {"role": "assistant", "content": "positive"},
14950                    "finish_reason": "stop"
14951                }],
14952                "usage": {"prompt_tokens": 16, "completion_tokens": 1}
14953            })))
14954            .mount(&server)
14955            .await;
14956
14957        let fixture = tempfile::tempdir().unwrap();
14958        let config = test_config(fixture.path().join("weights"));
14959        let repository =
14960            crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
14961        crate::resource_policy::ResourcePolicyRepository::save(
14962            &repository,
14963            &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
14964        )
14965        .unwrap();
14966        let mut engine = InferenceEngine::new(config);
14967        let mut remote = crate::openrouter::curated_schemas()
14968            .into_iter()
14969            .next()
14970            .expect("OpenRouter schema");
14971        remote.id = "openrouter/test/classifier".into();
14972        remote.name = remote.id.clone();
14973        if let ModelSource::RemoteApi { endpoint, .. } = &mut remote.source {
14974            *endpoint = server.uri();
14975        }
14976        let remote_id = remote.id.clone();
14977        engine.unified_registry.register_project_model(remote);
14978
14979        let result = engine
14980            .classify(ClassifyRequest {
14981                text: "a good outcome".into(),
14982                labels: vec!["positive".into(), "negative".into()],
14983                model: Some(remote_id),
14984            })
14985            .await
14986            .unwrap();
14987        assert_eq!(
14988            result.first().map(|item| item.label.as_str()),
14989            Some("positive")
14990        );
14991        assert_eq!(engine.local_admission.resident_model_mb(), 0);
14992        assert!(
14993            !engine.config.models_dir.exists()
14994                || std::fs::read_dir(&engine.config.models_dir)
14995                    .unwrap()
14996                    .next()
14997                    .is_none(),
14998            "remote classification must not download local weights"
14999        );
15000    }
15001
15002    #[test]
15003    fn adaptive_speech_skips_blocked_local_but_explicit_speech_fails() {
15004        let fixture = TempDir::new().unwrap();
15005        let config = InferenceConfig {
15006            models_dir: fixture.path().join("weights"),
15007            state_root: fixture.path().join("state"),
15008            ..Default::default()
15009        };
15010        let repository =
15011            crate::resource_policy::FileResourcePolicyRepository::new(config.state_root.clone());
15012        crate::resource_policy::ResourcePolicyRepository::save(
15013            &repository,
15014            &crate::resource_policy::ResourcePolicy::custom_gb(0.0).unwrap(),
15015        )
15016        .unwrap();
15017        let engine = InferenceEngine::new(config);
15018        let local = engine
15019            .unified_registry
15020            .all()
15021            .find(|schema| {
15022                schema.is_local() && schema.has_capability(ModelCapability::SpeechToText)
15023            })
15024            .cloned()
15025            .expect("built-in local STT model");
15026
15027        assert!(matches!(
15028            engine.admit_speech_candidate(&local, false),
15029            SpeechCandidateAdmission::SkipBlocked(_)
15030        ));
15031        assert!(matches!(
15032            engine.admit_speech_candidate(&local, true),
15033            SpeechCandidateAdmission::FailBlocked(InferenceError::LocalResourceBlocked { .. })
15034        ));
15035
15036        let mut os_owned = local;
15037        os_owned.id = "windows/speech-synthesis:test".into();
15038        os_owned.source = ModelSource::WindowsSpeech {};
15039        assert!(matches!(
15040            engine.admit_speech_candidate(&os_owned, true),
15041            SpeechCandidateAdmission::Proceed(None)
15042        ));
15043    }
15044
15045    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15046    async fn cancelled_native_blocking_work_keeps_its_exact_charge_until_completion() {
15047        let coordinator = std::sync::Arc::new(resource_policy::LocalAdmissionCoordinator::new(
15048            resource_policy::ResourcePolicy::custom_gb(1.0).unwrap(),
15049            crate::hardware::HardwareInfo {
15050                os: "test".into(),
15051                arch: "test".into(),
15052                cpu_cores: 8,
15053                total_ram_mb: 32 * 1024,
15054                gpu_backend: crate::hardware::GpuBackend::Cpu,
15055                gpu_memory_mb: None,
15056                gpu_devices: Vec::new(),
15057                recommended_model: "fixture".into(),
15058                recommended_context: 4096,
15059                max_model_mb: 32 * 1024,
15060            },
15061        ));
15062        let reservation = coordinator
15063            .reserve_measured_host("detached-native-a", 512 * 1024 * 1024, 0)
15064            .unwrap();
15065        let lease = reservation.detached_lease();
15066        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
15067        let release =
15068            std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
15069        let release_worker = release.clone();
15070        let operation = tokio::spawn(async move {
15071            let _request_reservation = reservation;
15072            run_admitted_blocking(lease, move || {
15073                let _ = started_tx.send(());
15074                let (lock, ready) = &*release_worker;
15075                let mut released = lock
15076                    .lock()
15077                    .unwrap_or_else(std::sync::PoisonError::into_inner);
15078                while !*released {
15079                    released = ready
15080                        .wait(released)
15081                        .unwrap_or_else(std::sync::PoisonError::into_inner);
15082                }
15083            })
15084            .await
15085        });
15086        started_rx.await.unwrap();
15087        operation.abort();
15088        let _ = operation.await;
15089
15090        let blocked = coordinator.reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0);
15091        assert!(
15092            blocked.is_err(),
15093            "cancelling the await must not advertise memory still owned by spawn_blocking"
15094        );
15095
15096        let (lock, ready) = &*release;
15097        *lock
15098            .lock()
15099            .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
15100        ready.notify_one();
15101        tokio::time::timeout(std::time::Duration::from_secs(2), async {
15102            loop {
15103                if coordinator
15104                    .reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0)
15105                    .is_ok()
15106                {
15107                    break;
15108                }
15109                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
15110            }
15111        })
15112        .await
15113        .expect("the detached charge must clear after native work really exits");
15114    }
15115
15116    #[test]
15117    fn nonstream_native_text_generation_uses_cancellable_blocking_boundary() {
15118        let source = include_str!("lib.rs");
15119        let mlx = source
15120            .split("async fn generate_mlx(")
15121            .nth(1)
15122            .and_then(|tail| tail.split("async fn generate_local(").next())
15123            .expect("generate_mlx source");
15124        let local = source
15125            .split("async fn generate_local(")
15126            .nth(1)
15127            .and_then(|tail| tail.split("fn apply_top_k_top_p").next())
15128            .expect("generate_local source");
15129
15130        assert!(
15131            mlx.contains("run_admitted_blocking"),
15132            "native MLX decode must yield the Tokio runtime so a WS timeout/cancel can preempt its response waiter"
15133        );
15134        assert!(
15135            local.contains("run_admitted_blocking"),
15136            "polymorphic local decode must yield the Tokio runtime so a WS timeout/cancel can preempt its response waiter"
15137        );
15138    }
15139
15140    #[cfg(unix)]
15141    #[tokio::test]
15142    async fn cancelled_speech_subprocess_is_killed_and_reaped_before_charge_clears() {
15143        use std::os::unix::fs::PermissionsExt;
15144
15145        let fixture = TempDir::new().unwrap();
15146        let runtime = SpeechRuntime::new(fixture.path().join("speech-runtime"));
15147        std::fs::create_dir_all(runtime.stt_program.parent().unwrap()).unwrap();
15148        std::fs::write(
15149            &runtime.stt_program,
15150            b"#!/bin/sh\necho started > \"$1\"\nsleep 1\necho continued > \"$2\"\n",
15151        )
15152        .unwrap();
15153        let mut permissions = std::fs::metadata(&runtime.stt_program)
15154            .unwrap()
15155            .permissions();
15156        permissions.set_mode(0o755);
15157        std::fs::set_permissions(&runtime.stt_program, permissions).unwrap();
15158
15159        let coordinator = std::sync::Arc::new(resource_policy::LocalAdmissionCoordinator::new(
15160            resource_policy::ResourcePolicy::custom_gb(1.0).unwrap(),
15161            crate::hardware::HardwareInfo {
15162                total_ram_mb: 32 * 1024,
15163                ..crate::hardware::HardwareInfo::detect()
15164            },
15165        ));
15166        let reservation = coordinator
15167            .reserve_measured_host("mlx-audio-a", 512 * 1024 * 1024, 0)
15168            .unwrap();
15169        let lease = reservation.detached_lease();
15170        let started = fixture.path().join("started");
15171        let continued = fixture.path().join("continued");
15172        let args = vec![
15173            started.display().to_string(),
15174            continued.display().to_string(),
15175        ];
15176        let command = tokio::spawn(async move {
15177            let _request_reservation = reservation;
15178            run_mlx_audio_command(&runtime, "stt.generate", &args, lease).await
15179        });
15180        tokio::time::timeout(std::time::Duration::from_secs(2), async {
15181            while !started.exists() {
15182                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
15183            }
15184        })
15185        .await
15186        .expect("speech fixture child must start");
15187        command.abort();
15188        let _ = command.await;
15189
15190        tokio::time::timeout(std::time::Duration::from_secs(2), async {
15191            loop {
15192                if coordinator
15193                    .reserve_measured_host("different-model-b", 768 * 1024 * 1024, 0)
15194                    .is_ok()
15195                {
15196                    break;
15197                }
15198                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
15199            }
15200        })
15201        .await
15202        .expect("charge clears only after the cancelled child is reaped");
15203        tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
15204        assert!(
15205            !continued.exists(),
15206            "the cancelled local speech process must not keep allocating in the background"
15207        );
15208    }
15209
15210    #[test]
15211    fn local_model_eviction_surface_includes_every_in_process_cache() {
15212        let source = include_str!("lib.rs");
15213        for cache in [
15214            "self.mlx_backends.evict_if_idle(model_id)",
15215            "self.local_backends.evict_if_idle(model_id)",
15216            "self.flux_cache.evict_if_idle(model_id)",
15217            "self.ltx_cache.evict_if_idle(model_id)",
15218            "self.kokoro_cache.evict_if_idle(model_id)",
15219            "self.local_backends.evict_idle()",
15220        ] {
15221            assert!(source.contains(cache), "missing eviction seam: {cache}");
15222        }
15223    }
15224
15225    /// Demand-driven credentials: constructing the real builtin catalog and
15226    /// rendering passive Home/Models/setup/speech/health surfaces must not
15227    /// touch any secret backend. The process-wide counter is the proof seam;
15228    /// a registry-local fake would miss provider helpers that construct their
15229    /// own `SecretStore`.
15230    #[tokio::test]
15231    async fn catalog_refresh_uses_authority_hints_without_secret_reads() {
15232        if !crate::run_in_isolated_test_process(
15233            "tests::catalog_refresh_uses_authority_hints_without_secret_reads",
15234            "CAR_CATALOG_ZERO_SECRET_READ_CHILD",
15235        ) {
15236            return;
15237        }
15238        let _environment = ENV_MUTEX.lock().await;
15239        let fixture = TempDir::new().unwrap();
15240        let credential_envs = [
15241            car_auth::PARSLEE_ACCESS_TOKEN_KEY,
15242            car_auth::PARSLEE_API_BASE_KEY,
15243            crate::openrouter::API_KEY_ENV,
15244            "OPENAI_API_KEY",
15245            "ANTHROPIC_API_KEY",
15246            "GOOGLE_API_KEY",
15247            "ELEVENLABS_API_KEY",
15248        ];
15249        let mut restored_names = vec![car_home::ENV_VAR, "CAR_SECRETS_FILE_DIR"];
15250        restored_names.extend(credential_envs);
15251        let _restore = RestoredEnvironment::capture(&restored_names);
15252        unsafe {
15253            std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
15254            std::env::set_var(
15255                "CAR_SECRETS_FILE_DIR",
15256                fixture.path().join("isolated-secrets"),
15257            );
15258            for name in credential_envs {
15259                std::env::remove_var(name);
15260            }
15261        }
15262
15263        let before = car_secrets::secret_store_activity();
15264        let engine = InferenceEngine::new(test_config(fixture.path().join("models")));
15265
15266        // Home / Models catalog.
15267        let listed = engine.list_models_unified();
15268        let schemas = engine.list_schemas();
15269        for provider in ["parslee", "openai", "anthropic", "google", "elevenlabs"] {
15270            assert!(
15271                schemas.iter().any(|schema| schema.provider == provider),
15272                "real builtin catalog lost the credential-bearing {provider} rows"
15273            );
15274        }
15275        assert!(
15276            schemas.iter().any(|schema| matches!(
15277                schema.source,
15278                ModelSource::RemoteApi {
15279                    protocol: ApiProtocol::OpenRouter,
15280                    ..
15281                }
15282            )),
15283            "real builtin catalog lost the reviewed OpenRouter rows"
15284        );
15285        assert_eq!(listed.len(), schemas.len());
15286
15287        // `models.setup_plan` delegates to this exact list + recommender path.
15288        let schema_refs: Vec<&ModelSchema> = schemas.iter().collect();
15289        let _setup_plan = crate::recommend(
15290            &schema_refs,
15291            &HardwareInfo::detect(),
15292            UseCase::default(),
15293            QualityTier::default(),
15294            Privacy::OnDevice,
15295        );
15296
15297        // Cached speech state plus Home/health status entry points.
15298        let _speech = engine.speech_health();
15299        let _concierge = engine.concierge_status(false).await;
15300        let _health = engine.model_health().await;
15301
15302        let after = car_secrets::secret_store_activity();
15303        assert_eq!(
15304            after, before,
15305            "passive builtin catalog surfaces performed secret-store operations"
15306        );
15307    }
15308
15309    #[tokio::test]
15310    async fn list_models_unified_and_model_health_are_zero_secret_store_probes() {
15311        if !crate::run_in_isolated_test_process(
15312            "tests::list_models_unified_and_model_health_are_zero_secret_store_probes",
15313            "CAR_DIRECT_MODEL_SURFACES_ZERO_SECRET_CHILD",
15314        ) {
15315            return;
15316        }
15317        let _environment = ENV_MUTEX.lock().await;
15318        let fixture = TempDir::new().unwrap();
15319        let _restore = RestoredEnvironment::capture(&[
15320            car_home::ENV_VAR,
15321            "CAR_SECRETS_FILE_DIR",
15322            crate::openrouter::API_KEY_ENV,
15323        ]);
15324        unsafe {
15325            std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
15326            std::env::set_var(
15327                "CAR_SECRETS_FILE_DIR",
15328                fixture.path().join("isolated-secrets"),
15329            );
15330            std::env::remove_var(crate::openrouter::API_KEY_ENV);
15331        }
15332        let engine = InferenceEngine::new(test_config(fixture.path().join("models")));
15333        let before = car_secrets::secret_store_activity();
15334
15335        let rows = engine.list_models_unified();
15336        let health = engine.model_health().await;
15337
15338        assert!(!rows.is_empty(), "the unified catalog fixture must exist");
15339        assert!(health.total_models > 0, "the health fixture must exist");
15340        let after = car_secrets::secret_store_activity();
15341        assert_eq!(after.status_attempts, before.status_attempts);
15342        assert_eq!(after.get_attempts, before.get_attempts);
15343    }
15344
15345    /// The signed-catalog cache holds an anti-rollback version counter and the
15346    /// discovery cache holds a provider model list — both are per-daemon
15347    /// bookkeeping, and both were derived from the *weights* dir, which
15348    /// deliberately stays machine-shared. A relocated daemon therefore kept
15349    /// writing them into the primary's `~/.car`, which is precisely the
15350    /// shared-state clobber `CAR_HOME` exists to prevent.
15351    ///
15352    /// The weights themselves must still NOT move, or every isolated daemon
15353    /// re-downloads tens of gigabytes to end up with identical bytes.
15354    #[test]
15355    fn car_home_moves_the_catalog_and_discovery_caches_but_never_the_weights() {
15356        let _environment = crate::openrouter::test_environment_scope();
15357        let prior = std::env::var_os(car_home::ENV_VAR);
15358
15359        unsafe { std::env::remove_var(car_home::ENV_VAR) };
15360        let shared = InferenceConfig::default();
15361        let default_catalog = crate::catalog::cache_path(&shared.state_root);
15362        let default_discovery = crate::discovery::cache_path(&shared.state_models_dir());
15363
15364        let alt = Path::new("/tmp/car-home-inference-cache-test");
15365        unsafe { std::env::set_var(car_home::ENV_VAR, alt) };
15366        let relocated = InferenceConfig::default();
15367        let catalog = crate::catalog::cache_path(&relocated.state_root);
15368        let discovery = crate::discovery::cache_path(&relocated.state_models_dir());
15369
15370        match prior {
15371            Some(value) => unsafe { std::env::set_var(car_home::ENV_VAR, value) },
15372            None => unsafe { std::env::remove_var(car_home::ENV_VAR) },
15373        }
15374
15375        assert_eq!(catalog, alt.join(crate::catalog::CATALOG_CACHE_FILE));
15376        assert_eq!(
15377            discovery,
15378            alt.join("models")
15379                .join(crate::discovery::DISCOVERED_MODELS_FILE)
15380        );
15381        assert_ne!(
15382            catalog, default_catalog,
15383            "the catalog cache must not resolve back into the shared root",
15384        );
15385        assert_ne!(
15386            discovery, default_discovery,
15387            "the discovery cache must not resolve back into the shared root",
15388        );
15389
15390        assert_eq!(
15391            relocated.models_dir, shared.models_dir,
15392            "the weights cache is machine-global and must not follow CAR_HOME",
15393        );
15394        assert!(
15395            !relocated.models_dir.starts_with(alt),
15396            "the weights cache must not be dragged under the override",
15397        );
15398    }
15399
15400    #[derive(Clone, Copy)]
15401    enum CacheRoutingSurface {
15402        Generate,
15403        Stream,
15404    }
15405
15406    /// Exercise cache-aware pricing through the public tracked generation
15407    /// surfaces, including adaptive selection and a real mocked OpenRouter HTTP
15408    /// request. This deliberately does not call the scorer directly: a routing
15409    /// field that exists only in `RouteRequest` but is dropped by either
15410    /// production call path must make these tests fail.
15411    async fn invoke_cache_routed_openrouter(
15412        surface: CacheRoutingSurface,
15413        cache_read_estimate: usize,
15414        cache_write_estimate: usize,
15415    ) -> String {
15416        use wiremock::matchers::{method, path};
15417        use wiremock::{Mock, MockServer, ResponseTemplate};
15418
15419        let _credential_scope = crate::openrouter::test_credential_scope();
15420        crate::openrouter::set_test_credential(Some("test-openrouter-key"));
15421        let server = MockServer::start().await;
15422        let response = match surface {
15423            CacheRoutingSurface::Generate => ResponseTemplate::new(200).set_body_json(
15424                serde_json::json!({
15425                    "choices": [{
15426                        "message": {"role": "assistant", "content": "cache-route-ok"},
15427                        "finish_reason": "stop"
15428                    }],
15429                    "usage": {"prompt_tokens": 40_000, "completion_tokens": 8}
15430                }),
15431            ),
15432            CacheRoutingSurface::Stream => ResponseTemplate::new(200).set_body_raw(
15433                concat!(
15434                    "data: {\"choices\":[{\"delta\":{\"content\":\"cache-route-ok\"},\"finish_reason\":\"stop\"}]}\n\n",
15435                    "data: [DONE]\n\n"
15436                ),
15437                "text/event-stream",
15438            ),
15439        };
15440        Mock::given(method("POST"))
15441            .and(path("/v1/chat/completions"))
15442            .respond_with(response)
15443            .mount(&server)
15444            .await;
15445
15446        let tmp = TempDir::new().unwrap();
15447        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
15448        let mut uncached_cheap = crate::openrouter::curated_schemas()
15449            .into_iter()
15450            .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
15451            .unwrap();
15452        uncached_cheap.id = "openrouter/test/uncached-cheap".into();
15453        uncached_cheap.name = uncached_cheap.id.clone();
15454        uncached_cheap.cost = CostModel {
15455            input_per_mtok: Some(1.0),
15456            output_per_mtok: Some(1.0),
15457            cache_read_input_per_mtok: Some(100.0),
15458            cache_write_input_per_mtok: Some(100.0),
15459            ..Default::default()
15460        };
15461        if let ModelSource::RemoteApi { endpoint, .. } = &mut uncached_cheap.source {
15462            *endpoint = server.uri();
15463        }
15464
15465        let mut cached_cheap = uncached_cheap.clone();
15466        cached_cheap.id = "openrouter/test/cached-cheap".into();
15467        cached_cheap.name = cached_cheap.id.clone();
15468        cached_cheap.cost = CostModel {
15469            input_per_mtok: Some(80.0),
15470            output_per_mtok: Some(1.0),
15471            cache_read_input_per_mtok: Some(0.001),
15472            cache_write_input_per_mtok: Some(0.001),
15473            ..Default::default()
15474        };
15475
15476        let uncached_id = uncached_cheap.id.clone();
15477        let cached_id = cached_cheap.id.clone();
15478        engine
15479            .unified_registry
15480            .register_project_model(uncached_cheap);
15481        engine.unified_registry.register_project_model(cached_cheap);
15482        let exclude_models = engine
15483            .list_schemas()
15484            .into_iter()
15485            .map(|schema| schema.id)
15486            .filter(|id| id != &uncached_id && id != &cached_id)
15487            .collect();
15488
15489        // bytes/4 => 40K estimated prompt tokens. An explicit read estimate is
15490        // clamped to that footprint; zero remains an honest "no cache knowledge"
15491        // rather than being inferred from cache_control.
15492        let mut params = GenerateParams {
15493            max_tokens: 8,
15494            ..Default::default()
15495        };
15496        assert_eq!(params.estimated_cache_read_input_tokens, 0);
15497        assert_eq!(params.estimated_cache_write_input_tokens, 0);
15498        params.estimated_cache_read_input_tokens = cache_read_estimate;
15499        params.estimated_cache_write_input_tokens = cache_write_estimate;
15500        let req = GenerateRequest {
15501            prompt: "x".repeat(160_000),
15502            params,
15503            cache_control: true,
15504            intent: Some(IntentHint {
15505                prefer_quality: true,
15506                exclude_models,
15507                ..Default::default()
15508            }),
15509            ..Default::default()
15510        };
15511
15512        match surface {
15513            CacheRoutingSurface::Generate => {
15514                engine
15515                    .generate_tracked(req)
15516                    .await
15517                    .expect("mocked OpenRouter generation should succeed")
15518                    .model_used
15519            }
15520            CacheRoutingSurface::Stream => {
15521                let mut handle = engine
15522                    .generate_tracked_stream(req)
15523                    .await
15524                    .expect("mocked OpenRouter stream should start");
15525                let selected = handle.model_used.clone();
15526                while handle.events.recv().await.is_some() {}
15527                selected
15528            }
15529        }
15530    }
15531
15532    #[tokio::test(flavor = "current_thread")]
15533    async fn tracked_generate_uses_explicit_cache_estimate_and_defaults_to_zero() {
15534        let without_estimate =
15535            invoke_cache_routed_openrouter(CacheRoutingSurface::Generate, 0, 0).await;
15536        let with_estimate =
15537            invoke_cache_routed_openrouter(CacheRoutingSurface::Generate, 40_000, 0).await;
15538        assert_eq!(without_estimate, "openrouter/test/uncached-cheap");
15539        assert_eq!(with_estimate, "openrouter/test/cached-cheap");
15540    }
15541
15542    #[tokio::test(flavor = "current_thread")]
15543    async fn tracked_stream_uses_explicit_cache_estimate_and_defaults_to_zero() {
15544        let without_estimate =
15545            invoke_cache_routed_openrouter(CacheRoutingSurface::Stream, 0, 0).await;
15546        let with_estimate =
15547            invoke_cache_routed_openrouter(CacheRoutingSurface::Stream, 0, 40_000).await;
15548        assert_eq!(without_estimate, "openrouter/test/uncached-cheap");
15549        assert_eq!(with_estimate, "openrouter/test/cached-cheap");
15550    }
15551
15552    #[tokio::test(flavor = "current_thread")]
15553    async fn authenticated_openrouter_registry_stays_static_and_rejects_unknown_ids() {
15554        let _credential_scope = crate::openrouter::test_credential_scope();
15555        crate::openrouter::set_test_credential(Some("static-key"));
15556        let tmp = TempDir::new().unwrap();
15557        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
15558        let personal: Vec<_> = engine
15559            .list_schemas()
15560            .into_iter()
15561            .filter(|schema| schema.id.starts_with("openrouter/"))
15562            .collect();
15563        assert_eq!(personal.len(), crate::openrouter::curated_model_count());
15564        assert!(personal.iter().all(ModelSchema::available_now));
15565        assert!(personal
15566            .iter()
15567            .all(|schema| schema.trust_tier == TrustTier::Curated));
15568        assert!(personal
15569            .iter()
15570            .all(|schema| !schema.tags.iter().any(|tag| tag == "dynamic")));
15571
15572        for unknown in [
15573            "openrouter/vendor/brand-new-model",
15574            "openrouter/openai/gpt-5.4-typo",
15575        ] {
15576            assert!(engine
15577                .list_schemas()
15578                .iter()
15579                .all(|schema| schema.id != unknown));
15580            assert_eq!(engine.model_context_window(unknown), 0);
15581            let error = engine
15582                .generate_tracked(GenerateRequest {
15583                    prompt: "must fail before transport".into(),
15584                    model: Some(unknown.into()),
15585                    params: GenerateParams {
15586                        strict_model: true,
15587                        ..Default::default()
15588                    },
15589                    ..Default::default()
15590                })
15591                .await
15592                .expect_err("unregistered personal OpenRouter ids must not reach inference");
15593            assert!(
15594                matches!(&error, InferenceError::ModelNotFound(id) if id == unknown),
15595                "{unknown}: {error}"
15596            );
15597            let stream_error = match engine
15598                .generate_tracked_stream(GenerateRequest {
15599                    prompt: "must fail before stream transport".into(),
15600                    model: Some(unknown.into()),
15601                    ..Default::default()
15602                })
15603                .await
15604            {
15605                Ok(_) => panic!("unregistered ids must also fail before streaming"),
15606                Err(error) => error,
15607            };
15608            assert!(
15609                matches!(&stream_error, InferenceError::ModelNotFound(id) if id == unknown),
15610                "{unknown}: {stream_error}"
15611            );
15612        }
15613    }
15614
15615    #[tokio::test(flavor = "current_thread")]
15616    async fn static_openrouter_rows_participate_in_adaptive_routing_only_with_a_key() {
15617        let _credential_scope = crate::openrouter::test_credential_scope();
15618        let _provider_env = crate::openrouter::test_environment_scope_async().await;
15619        let tmp = TempDir::new().unwrap();
15620        crate::openrouter::set_test_credential(Some("static-key"));
15621        unsafe {
15622            std::env::set_var("CAR_STATIC_ROUTING_PEER_KEY", "peer-key");
15623        }
15624        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
15625        let mut peer = remote_stream_fixture_schema(
15626            "test/openai-routing-peer",
15627            "http://127.0.0.1:9".into(),
15628            schema::ApiProtocol::OpenAiCompat,
15629            "CAR_STATIC_ROUTING_PEER_KEY",
15630        );
15631        peer.provider = "openai".into();
15632        peer.trust_tier = TrustTier::Curated;
15633        engine.unified_registry.register_project_model(peer);
15634        let reviewed: std::collections::HashSet<_> = engine
15635            .list_schemas()
15636            .into_iter()
15637            .filter(|schema| schema.id.starts_with("openrouter/"))
15638            .map(|schema| schema.id)
15639            .collect();
15640        assert_eq!(reviewed.len(), crate::openrouter::curated_model_count());
15641
15642        let with_key = engine
15643            .route_adaptive_with_intent(
15644                "Answer this simple question cheaply.",
15645                Some(IntentHint::default()),
15646            )
15647            .await;
15648        let openrouter_candidates: Vec<_> = std::iter::once(with_key.model_id.as_str())
15649            .chain(
15650                with_key
15651                    .candidates
15652                    .iter()
15653                    .map(|candidate| candidate.model_id.as_str()),
15654            )
15655            .chain(with_key.fallbacks.iter().map(String::as_str))
15656            .filter(|id| id.starts_with("openrouter/"))
15657            .collect();
15658        assert!(
15659            !openrouter_candidates.is_empty(),
15660            "keyed adaptive decision must include a reviewed OpenRouter row: {with_key:?}"
15661        );
15662        assert!(openrouter_candidates
15663            .iter()
15664            .all(|id| reviewed.contains(*id)));
15665        assert!(
15666            std::iter::once(with_key.model_id.as_str())
15667                .chain(with_key.fallbacks.iter().map(String::as_str),)
15668                .any(|id| !id.starts_with("openrouter/")),
15669            "fallback chain must retain cross-provider alternatives: {with_key:?}"
15670        );
15671
15672        crate::openrouter::set_test_credential(None);
15673        let without_key = engine
15674            .route_adaptive_with_intent(
15675                "Answer this simple question cheaply.",
15676                Some(IntentHint::default()),
15677            )
15678            .await;
15679        assert!(!std::iter::once(without_key.model_id.as_str())
15680            .chain(
15681                without_key
15682                    .candidates
15683                    .iter()
15684                    .map(|candidate| candidate.model_id.as_str()),
15685            )
15686            .chain(without_key.fallbacks.iter().map(String::as_str))
15687            .any(|id| id.starts_with("openrouter/")));
15688        unsafe {
15689            std::env::remove_var("CAR_STATIC_ROUTING_PEER_KEY");
15690        }
15691    }
15692
15693    #[tokio::test(flavor = "current_thread")]
15694    async fn v2_parslee_auth_drives_managed_registration_routing_lane_and_logout() {
15695        if !crate::run_in_isolated_test_process(
15696            "tests::v2_parslee_auth_drives_managed_registration_routing_lane_and_logout",
15697            "CAR_V2_ROUTING_AUTH_CHILD",
15698        ) {
15699            return;
15700        }
15701        let tmp = TempDir::new().unwrap();
15702        let _credential_scope = crate::openrouter::test_credential_scope();
15703        let _provider_env = crate::openrouter::test_environment_scope_async().await;
15704        let _restore = RestoredEnvironment::capture(&[
15705            "CAR_SECRETS_FILE_DIR",
15706            car_auth::PARSLEE_ACCESS_TOKEN_KEY,
15707        ]);
15708        // Same reason as the legacy sibling below: the managed-row assertions
15709        // read the durable gateway (Parslee-ai/car#786) and credential
15710        // (Parslee-ai/car#887) verdicts out of the CAR state root, and a live
15711        // one left there by any other process suppresses every `parslee/*` row
15712        // no matter what this test seeds. Pin the root and forget the
15713        // in-process copies rather than inheriting whatever the machine holds.
15714        let _home = crate::openrouter::StateRootScope::new();
15715        crate::openrouter::clear_gateway_unconfigured();
15716        crate::parslee_credential::clear_credential_rejected();
15717        let secrets_dir = tmp.path().join("secrets");
15718        unsafe {
15719            std::env::set_var("CAR_SECRETS_FILE_DIR", &secrets_dir);
15720            std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
15721        }
15722        crate::openrouter::set_test_credential(Some("personal-openrouter-key"));
15723
15724        let store = car_secrets::SecretStore::new();
15725        let state_ref =
15726            car_secrets::SecretRef::with_default_service(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
15727        store
15728            .publish(
15729                &state_ref,
15730                &serde_json::json!({
15731                    "schema": 2,
15732                    "revision": 7,
15733                    "generation": 3,
15734                    "active": {
15735                        "account_id": "account-v2",
15736                        "access_token": "v2-access",
15737                        "refresh_token": "v2-refresh",
15738                        "expires_at": 9_999_999_999_u64,
15739                        "api_base": "https://api.parslee.ai"
15740                    },
15741                    "accounts": [{
15742                        "account_id": "account-v2",
15743                        "access_token": "v2-access",
15744                        "refresh_token": "v2-refresh",
15745                        "expires_at": 9_999_999_999_u64,
15746                        "api_base": "https://api.parslee.ai"
15747                    }],
15748                    "tombstone": false
15749                })
15750                .to_string(),
15751            )
15752            .unwrap();
15753        assert_eq!(car_auth::access_token().as_deref(), Some("v2-access"));
15754        assert!(
15755            !store
15756                .status(&car_secrets::SecretRef::with_default_service(
15757                    car_auth::PARSLEE_ACCESS_TOKEN_KEY,
15758                ))
15759                .unwrap()
15760                .exists,
15761            "the regression must exercise V2 state without the import-only legacy slot"
15762        );
15763
15764        let managed_ids = [
15765            "parslee/openrouter/frontier-general",
15766            "parslee/openrouter/balanced-general",
15767        ];
15768        let personal_id = "openrouter/deepseek/deepseek-v3.2";
15769        let rogue_id = "community/custom-oauth";
15770        let schemas = crate::openrouter::curated_schemas();
15771        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("registry-models"));
15772        for id in managed_ids.into_iter().chain(std::iter::once(personal_id)) {
15773            registry.register_project_model(
15774                schemas
15775                    .iter()
15776                    .find(|schema| schema.id == id)
15777                    .unwrap_or_else(|| panic!("missing curated schema {id}"))
15778                    .clone(),
15779            );
15780        }
15781        registry.refresh_routing_availability(Some("https://api.parslee.ai"), false);
15782        for id in managed_ids {
15783            assert!(
15784                registry.get(id).unwrap().available_now(),
15785                "{id} must be available immediately when registered from V2 auth"
15786            );
15787        }
15788        assert!(registry.get(personal_id).unwrap().available_now());
15789        let mut rogue = schemas
15790            .iter()
15791            .find(|schema| schema.id == managed_ids[0])
15792            .unwrap()
15793            .clone();
15794        rogue.id = rogue_id.into();
15795        rogue.provider = "community".into();
15796        if let ModelSource::Proprietary {
15797            provider, endpoint, ..
15798        } = &mut rogue.source
15799        {
15800            *provider = "community".into();
15801            *endpoint = "https://untrusted.example".into();
15802        } else {
15803            panic!("managed fixture must remain proprietary");
15804        }
15805        registry.register(rogue);
15806        assert!(
15807            !registry.get(rogue_id).unwrap().available_now(),
15808            "a non-Parslee OAuth schema must not inherit Parslee V2 availability"
15809        );
15810
15811        registry.refresh_routing_availability(Some("https://api.parslee.ai"), false);
15812        for id in managed_ids {
15813            assert!(
15814                registry.get(id).unwrap().available_now(),
15815                "{id} must stay available in a refreshed V2-auth snapshot"
15816            );
15817        }
15818        assert!(
15819            !registry.get(rogue_id).unwrap().available_now(),
15820            "refresh must keep non-Parslee OAuth schemas unavailable"
15821        );
15822
15823        let router = AdaptiveRouter::new(
15824            crate::hardware::HardwareInfo::detect(),
15825            RoutingConfig {
15826                prefer_local: false,
15827                prior_strength: 1_000_000.0,
15828                quality_first_cold_start: false,
15829                ..RoutingConfig::default()
15830            },
15831        );
15832        let tracker = OutcomeTracker::new();
15833        let intent = IntentHint {
15834            task: Some(crate::intent::TaskHint::Chat),
15835            exclude_models: vec![personal_id.into()],
15836            ..Default::default()
15837        };
15838        let decision = router.route_with(crate::adaptive_router::RouteRequest {
15839            intent: Some(&intent),
15840            ..crate::adaptive_router::RouteRequest::new(
15841                "Explain this architecture.",
15842                &registry,
15843                &tracker,
15844            )
15845        });
15846        assert!(
15847            managed_ids.contains(&decision.model_id.as_str()),
15848            "managed V2-auth alias must be selectable: {decision:?}"
15849        );
15850        assert!(
15851            decision
15852                .candidates
15853                .iter()
15854                .any(|candidate| managed_ids.contains(&candidate.model_id.as_str())),
15855            "managed V2-auth alias must appear in adaptive candidates: {decision:?}"
15856        );
15857        assert!(
15858            decision
15859                .fallbacks
15860                .iter()
15861                .any(|id| managed_ids.contains(&id.as_str())),
15862            "managed V2-auth alias must appear in adaptive fallbacks: {decision:?}"
15863        );
15864        assert!(
15865            !std::iter::once(decision.model_id.as_str())
15866                .chain(
15867                    decision
15868                        .candidates
15869                        .iter()
15870                        .map(|candidate| candidate.model_id.as_str())
15871                )
15872                .chain(decision.fallbacks.iter().map(String::as_str))
15873                .any(|id| id == rogue_id),
15874            "a non-Parslee OAuth schema must never enter adaptive selection, candidates, or fallbacks: {decision:?}"
15875        );
15876
15877        let engine = InferenceEngine::new(test_config(tmp.path().join("engine-models")));
15878        let managed_lane_id = managed_ids[0];
15879        engine.lane_defaults_cache.write().unwrap().set(
15880            None,
15881            crate::intent::UseCase::Assistant,
15882            managed_lane_id.into(),
15883            1,
15884        );
15885        let request = GenerateRequest {
15886            prompt: "lane default".into(),
15887            intent: Some(IntentHint {
15888                task: Some(crate::intent::TaskHint::Chat),
15889                ..Default::default()
15890            }),
15891            ..Default::default()
15892        };
15893        assert_eq!(
15894            engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
15895            Some(managed_lane_id.to_string()),
15896            "a managed lane default must resolve from authoritative V2 auth"
15897        );
15898
15899        car_auth::logout()
15900            .await
15901            .expect("production logout must publish a signed-out tombstone");
15902        let persisted_logout: serde_json::Value =
15903            serde_json::from_str(&store.get(&state_ref).unwrap()).unwrap();
15904        assert_eq!(persisted_logout["tombstone"], true);
15905        assert_eq!(persisted_logout["accounts"], serde_json::json!([]));
15906        assert!(persisted_logout.get("active").is_none());
15907        assert_eq!(car_auth::access_token(), None);
15908
15909        registry.refresh_routing_availability(None, true);
15910        for id in managed_ids {
15911            assert!(
15912                !registry.get(id).unwrap().available_now(),
15913                "{id} must disappear from routing after the signed-out tombstone"
15914            );
15915        }
15916        assert!(
15917            registry.get(personal_id).unwrap().available_now(),
15918            "personal rows must continue to follow their independent personal-key seam"
15919        );
15920
15921        let signed_out = engine.routing_registry_snapshot().await;
15922        assert!(
15923            signed_out
15924                .list()
15925                .into_iter()
15926                .filter(|schema| schema.id.starts_with("parslee/openrouter/"))
15927                .all(|schema| !schema.available_now()),
15928            "the next engine snapshot must exclude every managed alias after logout"
15929        );
15930        assert!(
15931            signed_out.get(personal_id).unwrap().available_now(),
15932            "logout must not disable a still-keyed personal OpenRouter row"
15933        );
15934        assert_eq!(
15935            engine.lane_pin_for(&request, &signed_out),
15936            None,
15937            "a signed-out managed lane default must stop resolving"
15938        );
15939
15940        crate::openrouter::set_test_credential(None);
15941        registry.refresh_availability();
15942        assert!(
15943            !registry.get(personal_id).unwrap().available_now(),
15944            "personal row availability must still turn off with the personal-key seam"
15945        );
15946    }
15947
15948    #[tokio::test(flavor = "current_thread")]
15949    async fn legacy_parslee_auth_is_routable_only_until_v2_tombstone_then_migrates() {
15950        if !crate::run_in_isolated_test_process(
15951            "tests::legacy_parslee_auth_is_routable_only_until_v2_tombstone_then_migrates",
15952            "CAR_LEGACY_V2_MIGRATION_CHILD",
15953        ) {
15954            return;
15955        }
15956        let tmp = TempDir::new().unwrap();
15957        let _provider_env = crate::openrouter::test_environment_scope_async().await;
15958        let _restore = RestoredEnvironment::capture(&[
15959            "CAR_SECRETS_FILE_DIR",
15960            car_home::ENV_VAR,
15961            car_auth::PARSLEE_ACCESS_TOKEN_KEY,
15962        ]);
15963        // The managed-alias assertions below also read two DURABLE
15964        // session-scoped verdicts — `gateway-state.json` (Parslee-ai/car#786)
15965        // and `parslee-credential-state.json` (Parslee-ai/car#887) — either of
15966        // which suppresses every `parslee/openrouter/*` row regardless of what
15967        // this test seeded into its secret store.
15968        //
15969        // Until Parslee-ai/car#986 this test was repaired by a side effect it
15970        // never asked for: the tombstoned half's `refresh_availability` ran
15971        // signed out and cleared BOTH verdicts, in memory and on disk, before
15972        // the legacy half looked at them. Construction is not a statement about
15973        // the session, so that clear is gone — and with it the accidental
15974        // repair. Isolate the state root into this test's own temp dir and
15975        // forget the in-process copies, so the only verdicts in play are the
15976        // ones this test set itself.
15977        let _home = crate::openrouter::StateRootScope::new();
15978        crate::openrouter::clear_gateway_unconfigured();
15979        crate::parslee_credential::clear_credential_rejected();
15980        unsafe {
15981            std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
15982            std::env::set_var(car_home::ENV_VAR, tmp.path().join("car-home"));
15983        }
15984
15985        let secret = |key| car_secrets::SecretRef::with_default_service(key);
15986        let seed_legacy = |store: &car_secrets::SecretStore| {
15987            store
15988                .put(
15989                    &secret(car_secrets::PARSLEE_ACCESS_TOKEN_KEY),
15990                    "legacy-access",
15991                )
15992                .unwrap();
15993            store
15994                .put(
15995                    &secret(car_secrets::PARSLEE_ACTIVE_ACCOUNT_ID_KEY),
15996                    "legacy-account",
15997                )
15998                .unwrap();
15999            store.put(
16000                &secret(car_secrets::PARSLEE_ACCOUNTS_KEY),
16001                r#"{"active":"legacy-account","accounts":[{"id":"legacy-account","email":"legacy@example.test"}]}"#,
16002            )
16003            .unwrap();
16004        };
16005        let state_ref = secret(car_secrets::PARSLEE_AUTH_STATE_V2_KEY);
16006        let managed = crate::openrouter::curated_schemas()
16007            .into_iter()
16008            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
16009            .unwrap();
16010
16011        unsafe {
16012            std::env::set_var("CAR_SECRETS_FILE_DIR", tmp.path().join("tombstone-secrets"));
16013        }
16014        let tombstoned_store = car_secrets::SecretStore::new();
16015        seed_legacy(&tombstoned_store);
16016        tombstoned_store
16017            .publish(
16018                &state_ref,
16019                r#"{"schema":2,"revision":1,"generation":1,"accounts":[],"tombstone":true}"#,
16020            )
16021            .unwrap();
16022        let mut tombstoned_registry =
16023            UnifiedRegistry::new_empty(tmp.path().join("tombstoned-models"));
16024        tombstoned_registry.register_project_model(managed.clone());
16025        assert!(
16026            !tombstoned_registry
16027                .get(&managed.id)
16028                .unwrap()
16029                .available_now(),
16030            "passive catalog registration must remain disabled without a configured hint"
16031        );
16032        let tombstoned = car_auth::resolve_credential(car_auth::CredentialReadMode::Use)
16033            .await
16034            .unwrap();
16035        assert!(tombstoned.is_none(), "the V2 tombstone is authoritative");
16036        tombstoned_registry.refresh_routing_availability(None, true);
16037        assert!(!tombstoned_registry
16038            .get(&managed.id)
16039            .unwrap()
16040            .available_now());
16041
16042        unsafe {
16043            std::env::set_var(
16044                "CAR_SECRETS_FILE_DIR",
16045                tmp.path().join("legacy-only-secrets"),
16046            );
16047        }
16048        let legacy_store = car_secrets::SecretStore::new();
16049        seed_legacy(&legacy_store);
16050        assert!(!legacy_store.status(&state_ref).unwrap().exists);
16051        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("legacy-models"));
16052        registry.register_project_model(managed.clone());
16053        assert!(
16054            !registry.get(&managed.id).unwrap().available_now(),
16055            "passive registration must not inspect attributable legacy slots"
16056        );
16057        registry.refresh_availability();
16058        assert!(
16059            !registry.get(&managed.id).unwrap().available_now(),
16060            "passive refresh must remain secret-store free until request-time migration"
16061        );
16062        let resolved = car_auth::resolve_credential(car_auth::CredentialReadMode::Use)
16063            .await
16064            .unwrap()
16065            .expect("request-time auth reconciliation must migrate attributable legacy state");
16066        registry.refresh_routing_availability(Some(&resolved.api_base), false);
16067        assert!(registry.get(&managed.id).unwrap().available_now());
16068        assert!(
16069            legacy_store.status(&state_ref).unwrap().exists,
16070            "request-time auth reconciliation must publish the migrated V2 record"
16071        );
16072        assert!(
16073            !legacy_store
16074                .status(&secret(car_secrets::PARSLEE_ACCESS_TOKEN_KEY))
16075                .unwrap()
16076                .exists,
16077            "successful V2 migration must clean the legacy access slot"
16078        );
16079    }
16080
16081    #[tokio::test]
16082    async fn reviewed_openrouter_lane_default_tracks_live_credential_availability() {
16083        let _credential_scope = crate::openrouter::test_credential_scope();
16084        crate::openrouter::set_test_credential(Some("static-key"));
16085        let tmp = TempDir::new().unwrap();
16086        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16087        let reviewed_id = "openrouter/deepseek/deepseek-v3.2";
16088        engine.lane_defaults_cache.write().unwrap().set(
16089            None,
16090            crate::intent::UseCase::Assistant,
16091            reviewed_id.into(),
16092            1,
16093        );
16094        let request = GenerateRequest {
16095            prompt: "lane default".into(),
16096            intent: Some(IntentHint {
16097                task: Some(crate::intent::TaskHint::Chat),
16098                ..Default::default()
16099            }),
16100            ..Default::default()
16101        };
16102        assert_eq!(
16103            engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
16104            Some(reviewed_id.to_string()),
16105            "a reviewed keyed row must be eligible as a lane default"
16106        );
16107        crate::openrouter::set_test_credential(None);
16108        assert_eq!(
16109            engine.lane_pin_for(&request, &engine.routing_registry_snapshot().await),
16110            None,
16111            "the same static lane default must stop being eligible immediately after key removal"
16112        );
16113    }
16114
16115    #[tokio::test(flavor = "current_thread")]
16116    async fn personal_openrouter_baseline_remains_visible_but_disabled_without_a_credential() {
16117        let _credential_scope = crate::openrouter::test_credential_scope();
16118        crate::openrouter::set_test_credential(None);
16119        let tmp = TempDir::new().unwrap();
16120        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16121
16122        let personal: Vec<_> = engine
16123            .list_schemas()
16124            .into_iter()
16125            .filter(|schema| schema.id.starts_with("openrouter/"))
16126            .collect();
16127        assert_eq!(
16128            personal.len(),
16129            crate::openrouter::curated_model_count(),
16130            "the vetted personal rows stay discoverable as a disabled baseline"
16131        );
16132        assert!(
16133            personal.iter().all(|schema| !schema.available_now()),
16134            "no-key baseline rows must never become routing candidates"
16135        );
16136
16137        let error = engine
16138            .generate_tracked(GenerateRequest {
16139                prompt: "hello".into(),
16140                model: Some("openrouter/openai/gpt-5.4".into()),
16141                params: GenerateParams {
16142                    strict_model: true,
16143                    ..Default::default()
16144                },
16145                ..Default::default()
16146            })
16147            .await
16148            .expect_err("explicitly selecting a disabled baseline row must be actionable");
16149        let message = error.to_string();
16150        assert!(
16151            message.contains("car keys set openrouter") && message.contains("CarHost"),
16152            "disabled personal row must explain how to connect OpenRouter: {message}"
16153        );
16154    }
16155
16156    #[tokio::test]
16157    async fn reviewed_openrouter_metadata_stays_static_across_credential_changes() {
16158        let _credential_scope = crate::openrouter::test_credential_scope();
16159        crate::openrouter::set_test_credential(Some("static-key"));
16160
16161        let tmp = TempDir::new().unwrap();
16162        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16163        let live = engine.routing_registry_snapshot().await;
16164        let vetted = live.get("openrouter/openai/gpt-5.4").unwrap();
16165        assert!(vetted.available_now());
16166        assert_eq!(vetted.context_length, 1_050_000);
16167        assert_eq!(vetted.max_output_tokens, Some(128_000));
16168        assert_eq!(vetted.cost.input_per_mtok, Some(2.5));
16169        assert_eq!(vetted.cost.output_per_mtok, Some(15.0));
16170        assert!(
16171            !vetted.cost.pricing_tiers.is_empty(),
16172            "reviewed high-context pricing tiers must remain in the static row"
16173        );
16174        assert_eq!(vetted.trust_tier, TrustTier::Curated);
16175        assert!(vetted.tags.iter().any(|tag| tag == "frontier"));
16176        assert!(vetted.has_capability(ModelCapability::Code));
16177        assert!(vetted
16178            .supported_params
16179            .contains(&schema::GenerateParam::ExtendedThinking));
16180
16181        assert!(live.get("openrouter/vendor/unreviewed-model").is_none());
16182
16183        crate::openrouter::set_test_credential(None);
16184        let reverted = engine.routing_registry_snapshot().await;
16185        assert!(reverted.get("openrouter/vendor/unreviewed-model").is_none());
16186        let baseline = reverted.get("openrouter/openai/gpt-5.4").unwrap();
16187        assert!(
16188            !baseline.available_now(),
16189            "credential removal must disable the reviewed row without removing it"
16190        );
16191    }
16192
16193    /// Parslee-ai/car#651 — a model pulled against a **running** daemon must
16194    /// count as ready without a restart.
16195    ///
16196    /// `weights_ready` is assigned in `UnifiedRegistry::register`, and the
16197    /// daemon's engine is a `get_or_init` singleton behind an `Arc` with no
16198    /// interior mutability on the registry — so if that assignment were the
16199    /// only one, the flag would be frozen to the on-disk state at boot for the
16200    /// daemon's lifetime. The `require_ready` filter would keep skipping a
16201    /// freshly-pulled model, the soft fallback would drop `require_ready`
16202    /// entirely, and the remedy the #638 timeout message prints (`car models
16203    /// pull <id>`) would do nothing until a restart.
16204    ///
16205    /// It isn't the only one: `refresh_availability` recomputes `weights_ready`
16206    /// too, and every routing and listing entry point goes through
16207    /// `routing_registry_snapshot` (clone + refresh) rather than the frozen
16208    /// registry. This pins that contract from the outside — through the engine
16209    /// surface, with no `&mut` and no re-registration, exactly as the daemon
16210    /// holds it. `list_schemas` reads the same snapshot the router filters on.
16211    #[test]
16212    fn model_pulled_at_runtime_is_ready_without_a_daemon_restart() {
16213        let tmp = TempDir::new().unwrap();
16214        let models_dir = tmp.path().join("models");
16215        let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
16216        engine.register_model(ModelSchema {
16217            id: "mlx/pulled-later".into(),
16218            name: "PulledLater".into(),
16219            provider: "local".into(),
16220            family: "qwen3".into(),
16221            version: "test".into(),
16222            capabilities: vec![ModelCapability::Generate, ModelCapability::Code],
16223            context_length: 4096,
16224            max_output_tokens: None,
16225            param_count: String::new(),
16226            quantization: None,
16227            performance: schema::PerformanceEnvelope::default(),
16228            cost: schema::CostModel::default(),
16229            source: ModelSource::Mlx {
16230                hf_repo: "example/pulled-later".into(),
16231                hf_weight_file: None,
16232            },
16233            tags: vec![],
16234            supported_params: vec![],
16235            public_benchmarks: vec![],
16236            trust_tier: TrustTier::Curated,
16237            deprecated: false,
16238            available: true,
16239            weights_ready: false,
16240        });
16241
16242        // From here on the engine is shared-immutable — the daemon regime.
16243        let engine = &engine;
16244        let is_ready = || {
16245            engine
16246                .list_schemas()
16247                .into_iter()
16248                .find(|s| s.id == "mlx/pulled-later")
16249                .expect("registered model must be listed")
16250                .weights_ready
16251        };
16252
16253        assert!(!is_ready(), "precondition: no weights on disk yet");
16254
16255        // `car models pull` against the running daemon: weights land on disk
16256        // via `ensure_local` (&self), and nothing re-registers the schema.
16257        let dir = models_dir.join("PulledLater");
16258        std::fs::create_dir_all(&dir).unwrap();
16259        std::fs::write(dir.join("config.json"), "{}").unwrap();
16260        std::fs::write(dir.join("model.safetensors"), b"weights").unwrap();
16261
16262        assert!(
16263            is_ready(),
16264            "a model pulled at runtime must be ready without restarting the daemon"
16265        );
16266
16267        // And the inverse, so this can't pass on a flag that is merely stuck
16268        // true: weights removed out from under a live daemon stop being ready.
16269        std::fs::remove_file(dir.join("model.safetensors")).unwrap();
16270        assert!(
16271            !is_ready(),
16272            "readiness must track the disk in both directions, not latch"
16273        );
16274    }
16275
16276    /// Parslee-ai/car#650 — an out-of-credits account must not be recorded as
16277    /// the *model* being unreliable.
16278    ///
16279    /// A 402 (and 401/403) is account-wide: every model on that account fails
16280    /// it identically. Booking it through `record_failure` degraded the model's
16281    /// 30-day health EMA and tripped its per-model circuit breaker, and because
16282    /// the fallback loop walked every candidate on the account it did that to
16283    /// all of them at once. The user tops up their credits and the router keeps
16284    /// deprioritizing the models — a penalty that outlives its cause.
16285    ///
16286    /// The receipt is still written (operators need to see what happened); it
16287    /// just carries no success/quality verdict against the model.
16288    #[tokio::test(flavor = "current_thread")]
16289    async fn openrouter_out_of_credits_does_not_degrade_the_model() {
16290        use wiremock::matchers::{method, path};
16291        use wiremock::{Mock, MockServer, ResponseTemplate};
16292
16293        let _credential_scope = crate::openrouter::test_credential_scope();
16294        crate::openrouter::set_test_credential(Some("test-openrouter-key"));
16295        let server = MockServer::start().await;
16296        Mock::given(method("POST"))
16297            .and(path("/v1/chat/completions"))
16298            .respond_with(
16299                ResponseTemplate::new(402)
16300                    .set_body_string(r#"{"error":{"code":402,"message":"Insufficient credits"}}"#),
16301            )
16302            .mount(&server)
16303            .await;
16304
16305        let tmp = TempDir::new().unwrap();
16306        let models_dir = tmp.path().join("models");
16307        let mut engine = InferenceEngine::new(test_config(models_dir.clone()));
16308        let mut schema = crate::openrouter::curated_schemas()
16309            .into_iter()
16310            .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
16311            .unwrap();
16312        if let ModelSource::RemoteApi { endpoint, .. } = &mut schema.source {
16313            *endpoint = server.uri();
16314        }
16315        let model_id = schema.id.clone();
16316        engine.unified_registry.register_project_model(schema);
16317
16318        let err = engine
16319            .generate_tracked(GenerateRequest {
16320                prompt: "bill me".into(),
16321                model: Some(model_id.clone()),
16322                params: GenerateParams {
16323                    strict_model: true,
16324                    ..Default::default()
16325                },
16326                ..Default::default()
16327            })
16328            .await
16329            .expect_err("402 must not succeed");
16330
16331        match &err {
16332            InferenceError::ProviderAccount {
16333                provider, status, ..
16334            } => {
16335                assert_eq!(status, &402);
16336                assert_eq!(provider, "openrouter");
16337            }
16338            other => panic!("expected ProviderAccount, got {other:?}"),
16339        }
16340        assert!(
16341            !error_counts_against_circuit_breaker(&err),
16342            "an account rejection must not feed the per-model breaker"
16343        );
16344
16345        // The model's routing profile carries no failure from someone's billing.
16346        let tracker_handle = engine.outcome_tracker();
16347        let tracker = tracker_handle.read().await;
16348        let profile = tracker.profile(&model_id).cloned();
16349        assert!(
16350            profile.as_ref().is_none_or(|p| p.fail_count == 0),
16351            "account rejection degraded the model profile: {profile:?}"
16352        );
16353        drop(tracker);
16354
16355        // ...but the attempt is still on the receipt ledger, unattributed. The
16356        // post-call auto-save has already drained the in-memory buffer to disk,
16357        // so read it back from where operators actually look.
16358        let ledger = crate::outcome::read_ledger(&models_dir.join("outcome_ledger.jsonl"), 0);
16359        let entry = ledger
16360            .iter()
16361            .find(|e| e.model_id == model_id)
16362            .expect("the attempt must still produce a receipt");
16363        assert!(
16364            entry.success.is_none() && entry.quality.is_none(),
16365            "receipt must record the attempt without a verdict: {entry:?}"
16366        );
16367
16368        // And the breaker was never touched for this model.
16369        assert!(
16370            engine
16371                .adaptive_router
16372                .circuit_breakers
16373                .lock()
16374                .unwrap()
16375                .state(&model_id)
16376                .is_none(),
16377            "account rejection must not create breaker state for the model"
16378        );
16379    }
16380
16381    #[tokio::test(flavor = "current_thread")]
16382    async fn openrouter_stream_error_records_failure_and_never_completes_successfully() {
16383        use wiremock::matchers::{method, path};
16384        use wiremock::{Mock, MockServer, ResponseTemplate};
16385
16386        let _credential_scope = crate::openrouter::test_credential_scope();
16387        crate::openrouter::set_test_credential(Some("test-openrouter-key"));
16388        let server = MockServer::start().await;
16389        Mock::given(method("POST"))
16390            .and(path("/v1/chat/completions"))
16391            .respond_with(ResponseTemplate::new(200).set_body_raw(
16392                "data: {\"error\":{\"code\":402,\"message\":\"private balance details\"}}\n\n",
16393                "text/event-stream",
16394            ))
16395            .mount(&server)
16396            .await;
16397
16398        let tmp = TempDir::new().unwrap();
16399        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16400        let mut schema = crate::openrouter::curated_schemas()
16401            .into_iter()
16402            .find(|schema| schema.id == "openrouter/deepseek/deepseek-v3.2")
16403            .unwrap();
16404        if let ModelSource::RemoteApi { endpoint, .. } = &mut schema.source {
16405            *endpoint = server.uri();
16406        }
16407        let model_id = schema.id.clone();
16408        engine.unified_registry.register_project_model(schema);
16409
16410        let mut handle = engine
16411            .generate_tracked_stream(GenerateRequest {
16412                prompt: "fail after headers".into(),
16413                model: Some(model_id.clone()),
16414                ..Default::default()
16415            })
16416            .await
16417            .unwrap();
16418        let mut events = Vec::new();
16419        while let Some(event) = handle.events.recv().await {
16420            events.push(event);
16421        }
16422        assert!(matches!(
16423            events.as_slice(),
16424            [StreamEvent::Error(message)] if message == "OpenRouter account is out of credits"
16425        ));
16426        assert!(!events
16427            .iter()
16428            .any(|event| matches!(event, StreamEvent::Done { .. })));
16429
16430        for _ in 0..50 {
16431            if engine
16432                .outcome_tracker()
16433                .read()
16434                .await
16435                .profile(&model_id)
16436                .is_some_and(|profile| profile.fail_count == 1)
16437            {
16438                break;
16439            }
16440            tokio::task::yield_now().await;
16441        }
16442        let tracker = engine.outcome_tracker();
16443        let profile = tracker.read().await.profile(&model_id).cloned().unwrap();
16444        assert_eq!(profile.fail_count, 1);
16445        assert_eq!(profile.success_count, 0);
16446    }
16447
16448    #[tokio::test(flavor = "current_thread")]
16449    async fn tracked_stream_without_done_records_failure_not_success() {
16450        let _offload_guard = crate::offload::test_offload_lock().lock().await;
16451        let tmp = TempDir::new().unwrap();
16452        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16453        pin_test_live_memory(&mut engine);
16454        let model_id = install_small_local_fixture(&engine);
16455        crate::offload::set_local_offload(Some(Arc::new(FixtureLocalOffload { emit_done: false })));
16456
16457        let mut stream = engine
16458            .generate_tracked_stream(GenerateRequest {
16459                prompt: "must not count as success".into(),
16460                model: Some(model_id.clone()),
16461                params: GenerateParams {
16462                    strict_model: true,
16463                    ..Default::default()
16464                },
16465                ..Default::default()
16466            })
16467            .await
16468            .unwrap();
16469        while stream.events.recv().await.is_some() {}
16470        crate::offload::set_local_offload(None);
16471
16472        for _ in 0..50 {
16473            if engine
16474                .outcome_tracker()
16475                .read()
16476                .await
16477                .profile(&model_id)
16478                .is_some_and(|profile| profile.fail_count == 1)
16479            {
16480                break;
16481            }
16482            tokio::task::yield_now().await;
16483        }
16484        let profile = engine
16485            .outcome_tracker()
16486            .read()
16487            .await
16488            .profile(&model_id)
16489            .cloned()
16490            .unwrap();
16491        assert_eq!(profile.fail_count, 1);
16492        assert_eq!(profile.success_count, 0);
16493    }
16494
16495    #[tokio::test(flavor = "current_thread")]
16496    async fn google_vertex_terminal_matrix_records_only_deliberate_finishes_as_success() {
16497        use wiremock::matchers::{method, path};
16498        use wiremock::{Mock, MockServer, ResponseTemplate};
16499
16500        let _provider_env = crate::openrouter::test_environment_scope_async().await;
16501        let _env = ENV_MUTEX.lock().await;
16502        unsafe { std::env::set_var("CAR_GOOGLE_OUTCOME_MATRIX_KEY", "matrix-key") };
16503
16504        for (protocol, reason, should_succeed) in [
16505            (schema::ApiProtocol::Google, "STOP", true),
16506            (schema::ApiProtocol::Google, "SAFETY", false),
16507            (schema::ApiProtocol::VertexAi, "MAX_TOKENS", true),
16508            (
16509                schema::ApiProtocol::VertexAi,
16510                "MALFORMED_FUNCTION_CALL",
16511                false,
16512            ),
16513        ] {
16514            let server = MockServer::start().await;
16515            let expected_path = match protocol {
16516                schema::ApiProtocol::Google => "/v1beta/models/gemini-test:streamGenerateContent",
16517                schema::ApiProtocol::VertexAi => {
16518                    "/publishers/google/models/gemini-test:streamGenerateContent"
16519                }
16520                _ => unreachable!(),
16521            };
16522            Mock::given(method("POST"))
16523                .and(path(expected_path))
16524                .respond_with(ResponseTemplate::new(200).set_body_raw(
16525                    format!(
16526                        "data: {{\"candidates\":[{{\"content\":{{\"parts\":[{{\"text\":\"matrix\"}}]}},\"finishReason\":\"{reason}\"}}]}}\n\n"
16527                    ),
16528                    "text/event-stream",
16529                ))
16530                .mount(&server)
16531                .await;
16532
16533            let id = format!("test/{protocol:?}-{reason}");
16534            let tmp = TempDir::new().unwrap();
16535            let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16536            engine.register_model(remote_stream_fixture_schema(
16537                &id,
16538                server.uri(),
16539                protocol,
16540                "CAR_GOOGLE_OUTCOME_MATRIX_KEY",
16541            ));
16542            let mut stream = engine
16543                .generate_tracked_stream(GenerateRequest {
16544                    prompt: "matrix".into(),
16545                    model: Some(id.clone()),
16546                    params: GenerateParams {
16547                        strict_model: true,
16548                        ..Default::default()
16549                    },
16550                    ..Default::default()
16551                })
16552                .await
16553                .unwrap();
16554            let mut events = Vec::new();
16555            while let Some(event) = stream.events.recv().await {
16556                events.push(event);
16557            }
16558            for _ in 0..50 {
16559                if engine
16560                    .outcome_tracker()
16561                    .read()
16562                    .await
16563                    .profile(&id)
16564                    .is_some_and(|profile| profile.total_calls == 1)
16565                {
16566                    break;
16567                }
16568                tokio::task::yield_now().await;
16569            }
16570            let profile = engine
16571                .outcome_tracker()
16572                .read()
16573                .await
16574                .profile(&id)
16575                .cloned()
16576                .unwrap();
16577            assert_eq!(
16578                (profile.success_count, profile.fail_count),
16579                if should_succeed { (1, 0) } else { (0, 1) },
16580                "{protocol:?}/{reason}: {events:?}"
16581            );
16582        }
16583
16584        unsafe { std::env::remove_var("CAR_GOOGLE_OUTCOME_MATRIX_KEY") };
16585    }
16586
16587    #[tokio::test(flavor = "current_thread")]
16588    async fn remote_primary_stream_setup_failure_falls_back_to_installed_local_dispatch() {
16589        use wiremock::matchers::{method, path};
16590        use wiremock::{Mock, MockServer, ResponseTemplate};
16591
16592        let _offload_guard = crate::offload::test_offload_lock().lock().await;
16593        let _env = ENV_MUTEX.lock().await;
16594        let server = MockServer::start().await;
16595        Mock::given(method("POST"))
16596            .and(path("/v1/chat/completions"))
16597            .respond_with(ResponseTemplate::new(503))
16598            .mount(&server)
16599            .await;
16600        unsafe { std::env::set_var("CAR_STREAM_FALLBACK_TEST_KEY", "fixture") };
16601
16602        let tmp = TempDir::new().unwrap();
16603        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16604        pin_test_live_memory(&mut engine);
16605        let _local_id = install_small_local_fixture(&engine);
16606        let remote_id = "test/remote-primary";
16607        engine.register_model(ModelSchema {
16608            id: remote_id.into(),
16609            name: "remote-primary".into(),
16610            provider: "test".into(),
16611            family: "test".into(),
16612            version: "1".into(),
16613            capabilities: vec![ModelCapability::Generate],
16614            context_length: 8_192,
16615            max_output_tokens: Some(1_024),
16616            param_count: String::new(),
16617            quantization: None,
16618            performance: Default::default(),
16619            cost: Default::default(),
16620            source: ModelSource::RemoteApi {
16621                endpoint: server.uri(),
16622                api_key_env: "CAR_STREAM_FALLBACK_TEST_KEY".into(),
16623                api_key_envs: vec![],
16624                api_version: None,
16625                protocol: schema::ApiProtocol::OpenAiCompat,
16626            },
16627            tags: vec![],
16628            supported_params: vec![],
16629            public_benchmarks: vec![],
16630            trust_tier: TrustTier::Community,
16631            deprecated: false,
16632            available: true,
16633            weights_ready: true,
16634        });
16635        crate::offload::set_local_offload(Some(Arc::new(FixtureLocalOffload { emit_done: true })));
16636
16637        let mut stream = engine
16638            .generate_tracked_stream(GenerateRequest {
16639                prompt: "fall back".into(),
16640                model: Some(remote_id.into()),
16641                ..Default::default()
16642            })
16643            .await
16644            .expect("compatible installed local model should be dispatched");
16645        assert_ne!(stream.model_used, remote_id);
16646        assert!(
16647            engine
16648                .unified_registry
16649                .get(&stream.model_used)
16650                .is_some_and(ModelSchema::is_local),
16651            "fallback must stay on a compatible local model: {}",
16652            stream.model_used
16653        );
16654        let mut saw_done = false;
16655        while let Some(event) = stream.events.recv().await {
16656            saw_done |= matches!(event, StreamEvent::Done { .. });
16657        }
16658        assert!(saw_done);
16659
16660        crate::offload::set_local_offload(None);
16661        unsafe { std::env::remove_var("CAR_STREAM_FALLBACK_TEST_KEY") };
16662    }
16663
16664    #[tokio::test(flavor = "current_thread")]
16665    async fn exact_model_id_rejects_display_name_without_dispatch() {
16666        let tmp = TempDir::new().unwrap();
16667        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16668        let mut first = remote_stream_fixture_schema(
16669            "test/ambiguous-a:1",
16670            "http://127.0.0.1:9".into(),
16671            schema::ApiProtocol::OpenAiCompat,
16672            "CAR_AMBIGUOUS_EXACT_KEY",
16673        );
16674        first.name = "Shared Alias".into();
16675        let mut second = first.clone();
16676        second.id = "test/ambiguous-b:1".into();
16677        engine.register_model(first);
16678        engine.register_model(second);
16679        let mut request = GenerateRequest {
16680            prompt: "must not dispatch".into(),
16681            ..Default::default()
16682        };
16683        pin_exact_model_id(&mut request, "Shared Alias".into()).unwrap();
16684
16685        let error = engine
16686            .generate_tracked(request)
16687            .await
16688            .expect_err("an exact-id pin must never resolve a display name");
16689        assert!(matches!(error, InferenceError::ModelNotFound(_)));
16690    }
16691
16692    #[tokio::test(flavor = "current_thread")]
16693    async fn exact_openai_pin_reports_catalog_id_and_loose_request_keeps_provider_name() {
16694        let _env = ENV_MUTEX.lock().await;
16695        assert_remote_model_identity_contract(
16696            schema::ApiProtocol::OpenAiCompat,
16697            "openai/newsroom-gpt-5.5-2026-04-23:test",
16698            "newsroom-gpt-5.5-2026-04-23-test",
16699            "CAR_OPENAI_IDENTITY_TEST_KEY",
16700        )
16701        .await;
16702    }
16703
16704    #[tokio::test(flavor = "current_thread")]
16705    async fn exact_anthropic_pin_reports_catalog_id_and_loose_request_keeps_provider_name() {
16706        let _env = ENV_MUTEX.lock().await;
16707        assert_remote_model_identity_contract(
16708            schema::ApiProtocol::Anthropic,
16709            "anthropic/newsroom-claude-opus-4-8:test",
16710            "newsroom-claude-opus-4-8-test",
16711            "CAR_ANTHROPIC_IDENTITY_TEST_KEY",
16712        )
16713        .await;
16714    }
16715
16716    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
16717    #[tokio::test(flavor = "current_thread")]
16718    async fn exact_model_id_nonstream_bypasses_mlx_equivalent_substitution() {
16719        let _offload_guard = crate::offload::test_offload_lock().lock().await;
16720        let tmp = TempDir::new().unwrap();
16721        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16722        let exact_id = install_exact_pin_equivalent_fixture(&engine);
16723        let offload = Arc::new(ExactPinCaptureOffload::new());
16724        crate::offload::set_local_offload(Some(offload.clone()));
16725
16726        let mut request = GenerateRequest {
16727            prompt: "use only the immutable row I selected".into(),
16728            ..Default::default()
16729        };
16730        pin_exact_model_id(&mut request, exact_id.clone()).unwrap();
16731        let error = engine.generate_tracked(request).await.unwrap_err();
16732
16733        assert!(offload.dispatched_models().is_empty());
16734        assert!(error.to_string().contains(&exact_id), "{error}");
16735        assert!(
16736            error
16737                .to_string()
16738                .contains("MLX-equivalent substitution is disabled for exact pins"),
16739            "{error}"
16740        );
16741        crate::offload::set_local_offload(None);
16742    }
16743
16744    #[tokio::test(flavor = "current_thread")]
16745    async fn catalog_identity_mismatch_rejects_before_provider_dispatch() {
16746        use wiremock::matchers::{method, path};
16747        use wiremock::{Mock, MockServer, ResponseTemplate};
16748
16749        let _env = ENV_MUTEX.lock().await;
16750        let server = MockServer::start().await;
16751        Mock::given(method("POST"))
16752            .and(path("/v1/chat/completions"))
16753            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
16754                "choices": [{"message": {"content": "must not run"}, "finish_reason": "stop"}],
16755            })))
16756            .mount(&server)
16757            .await;
16758        unsafe { std::env::set_var("CAR_PRECONDITION_TEST_KEY", "fixture") };
16759
16760        let tmp = TempDir::new().unwrap();
16761        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16762        let model_id = "test/catalog-precondition:1";
16763        engine.register_model(remote_stream_fixture_schema(
16764            model_id,
16765            server.uri(),
16766            schema::ApiProtocol::OpenAiCompat,
16767            "CAR_PRECONDITION_TEST_KEY",
16768        ));
16769        let snapshot = engine.catalog_snapshot().unwrap();
16770        let row_digest = snapshot
16771            .model_by_exact_id(model_id)
16772            .unwrap()
16773            .row_digest
16774            .clone();
16775
16776        for (expected_row_digest, expected_catalog_revision) in [
16777            (
16778                Some("0".repeat(64)),
16779                Some(snapshot.catalog_revision.clone()),
16780            ),
16781            (Some(row_digest.clone()), Some("f".repeat(64))),
16782        ] {
16783            let mut request = GenerateRequest {
16784                prompt: "must not dispatch".into(),
16785                expected_row_digest,
16786                expected_catalog_revision,
16787                ..Default::default()
16788            };
16789            pin_exact_model_id(&mut request, model_id.into()).unwrap();
16790            let error = engine.generate_tracked(request).await.unwrap_err();
16791            assert!(matches!(
16792                error,
16793                InferenceError::CatalogPreconditionMismatch { .. }
16794            ));
16795        }
16796        assert!(
16797            server.received_requests().await.unwrap().is_empty(),
16798            "catalog identity mismatches must fail before provider dispatch"
16799        );
16800
16801        unsafe { std::env::remove_var("CAR_PRECONDITION_TEST_KEY") };
16802    }
16803
16804    #[tokio::test(flavor = "current_thread")]
16805    async fn exact_nonstream_identity_survives_thinking_retry() {
16806        use std::sync::atomic::{AtomicUsize, Ordering};
16807        use wiremock::matchers::{method, path};
16808        use wiremock::{Mock, MockServer, ResponseTemplate};
16809
16810        let _env = ENV_MUTEX.lock().await;
16811        let server = MockServer::start().await;
16812        let calls = Arc::new(AtomicUsize::new(0));
16813        let response_calls = calls.clone();
16814        Mock::given(method("POST"))
16815            .and(path("/v1/chat/completions"))
16816            .respond_with(move |_request: &wiremock::Request| {
16817                let attempt = response_calls.fetch_add(1, Ordering::SeqCst);
16818                let content = if attempt == 0 { "" } else { "recovered" };
16819                ResponseTemplate::new(200).set_body_json(serde_json::json!({
16820                    "choices": [{
16821                        "message": {"content": content},
16822                        "finish_reason": if attempt == 0 { "length" } else { "stop" },
16823                    }],
16824                    "usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3},
16825                }))
16826            })
16827            .mount(&server)
16828            .await;
16829        unsafe { std::env::set_var("CAR_IDENTITY_RETRY_KEY", "fixture") };
16830
16831        let tmp = TempDir::new().unwrap();
16832        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16833        let model_id = "test/identity-retry:1";
16834        engine.register_model(remote_stream_fixture_schema(
16835            model_id,
16836            server.uri(),
16837            schema::ApiProtocol::OpenAiCompat,
16838            "CAR_IDENTITY_RETRY_KEY",
16839        ));
16840        let snapshot = engine.catalog_snapshot().unwrap();
16841        let expected_row = snapshot.model_by_exact_id(model_id).unwrap();
16842        let mut request = GenerateRequest {
16843            prompt: "retry once".into(),
16844            expected_row_digest: Some(expected_row.row_digest.clone()),
16845            expected_catalog_revision: Some(snapshot.catalog_revision.clone()),
16846            ..Default::default()
16847        };
16848        pin_exact_model_id(&mut request, model_id.into()).unwrap();
16849
16850        let result = engine.generate_tracked(request).await.unwrap();
16851        assert_eq!(calls.load(Ordering::SeqCst), 2, "must exercise retry path");
16852        assert_eq!(result.stop_reason.as_deref(), Some("thinking_recovered"));
16853        assert_eq!(
16854            result.model_identity.requested_model_id.as_deref(),
16855            Some(model_id)
16856        );
16857        assert_eq!(result.model_identity.resolved_model_id, model_id);
16858        assert_eq!(result.model_identity.row_digest, expected_row.row_digest);
16859        assert_eq!(
16860            result.model_identity.catalog_revision,
16861            snapshot.catalog_revision
16862        );
16863
16864        unsafe { std::env::remove_var("CAR_IDENTITY_RETRY_KEY") };
16865    }
16866
16867    #[tokio::test(flavor = "current_thread")]
16868    async fn managed_openrouter_reasoning_items_roundtrip_verbatim_across_two_turns() {
16869        if !crate::run_in_isolated_test_process(
16870            "tests::managed_openrouter_reasoning_items_roundtrip_verbatim_across_two_turns",
16871            "CAR_MANAGED_REASONING_ROUNDTRIP_CHILD",
16872        ) {
16873            return;
16874        }
16875        let _home = crate::openrouter::StateRootScope::new();
16876        use wiremock::matchers::{header, method, path};
16877        use wiremock::{Mock, MockServer, ResponseTemplate};
16878
16879        let _provider_env = crate::openrouter::test_environment_scope_async().await;
16880        let _env = ENV_MUTEX.lock().await;
16881        let bearer = "managed-reasoning-roundtrip-bearer";
16882
16883        let server = MockServer::start().await;
16884        unsafe {
16885            std::env::set_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV, bearer);
16886            std::env::set_var(car_auth::PARSLEE_API_BASE_KEY, server.uri());
16887        }
16888        Mock::given(method("GET"))
16889            .and(path("/api/v1/organizations/me"))
16890            .and(header("authorization", format!("Bearer {bearer}")))
16891            .respond_with(
16892                ResponseTemplate::new(200)
16893                    .set_body_json(serde_json::json!({"organizationId": "org-roundtrip"})),
16894            )
16895            .mount(&server)
16896            .await;
16897        Mock::given(method("GET"))
16898            .and(path("/connect/session"))
16899            .respond_with(
16900                ResponseTemplate::new(200)
16901                    .set_body_json(serde_json::json!({"account": {"email": "user@example.test"}})),
16902            )
16903            .mount(&server)
16904            .await;
16905        Mock::given(method("POST"))
16906            .and(path("/api/v1/orgs/org-roundtrip/inference/responses"))
16907            .respond_with(ResponseTemplate::new(200).set_body_raw(
16908                include_str!("../tests/fixtures/parslee-openrouter-reasoning-roundtrip.sse"),
16909                "text/event-stream",
16910            ))
16911            .expect(2)
16912            .mount(&server)
16913            .await;
16914
16915        let tmp = TempDir::new().unwrap();
16916        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
16917        let schema = crate::openrouter::curated_schemas()
16918            .into_iter()
16919            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
16920            .unwrap();
16921        engine.unified_registry.register_project_model(schema);
16922
16923        let first = engine
16924            .generate_tracked(GenerateRequest {
16925                prompt: "first".into(),
16926                model: Some("parslee/openrouter/frontier-general".into()),
16927                ..Default::default()
16928            })
16929            .await
16930            .expect("first managed turn");
16931        let expected_reasoning = serde_json::json!({
16932            "type": "reasoning",
16933            "id": "rs_car_roundtrip",
16934            "status": "completed",
16935            "summary": [{"type": "summary_text", "text": "safe summary"}],
16936            "encrypted_content": "opaque-encrypted-reasoning",
16937        });
16938        assert_eq!(
16939            first.provider_output_items,
16940            vec![expected_reasoning.clone()]
16941        );
16942
16943        let mut history = vec![Message::User {
16944            content: "first".into(),
16945        }];
16946        first.append_assistant_history(&mut history, first.tool_calls.clone());
16947        history.push(Message::User {
16948            content: "continue".into(),
16949        });
16950
16951        let second = engine
16952            .generate_tracked(GenerateRequest {
16953                prompt: String::new(),
16954                model: Some("parslee/openrouter/frontier-general".into()),
16955                messages: Some(history),
16956                ..Default::default()
16957            })
16958            .await
16959            .expect("second managed turn");
16960        assert_eq!(second.text, "first answer");
16961
16962        let requests = server.received_requests().await.unwrap();
16963        let posts: Vec<serde_json::Value> = requests
16964            .iter()
16965            .filter(|request| {
16966                request.method.as_str() == "POST"
16967                    && request.url.path() == "/api/v1/orgs/org-roundtrip/inference/responses"
16968            })
16969            .map(|request| serde_json::from_slice(&request.body).unwrap())
16970            .collect();
16971        assert_eq!(posts.len(), 2);
16972        for body in &posts {
16973            assert_eq!(body["store"], false);
16974            assert_eq!(
16975                body["include"],
16976                serde_json::json!(["reasoning.encrypted_content"])
16977            );
16978        }
16979        let second_input = posts[1]["input"].as_array().unwrap();
16980        let reasoning_index = second_input
16981            .iter()
16982            .position(|item| item == &expected_reasoning)
16983            .expect("second request must replay the exact reasoning item");
16984        let assistant_index = second_input
16985            .iter()
16986            .position(|item| item["role"] == "assistant")
16987            .expect("second request assistant turn");
16988        let user_index = second_input
16989            .iter()
16990            .position(|item| item["content"] == "continue")
16991            .expect("second request user turn");
16992        assert!(
16993            reasoning_index < assistant_index && assistant_index < user_index,
16994            "provider output order must be reasoning, assistant text, then the next user turn"
16995        );
16996
16997        unsafe {
16998            std::env::remove_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV);
16999            std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
17000        }
17001    }
17002
17003    #[tokio::test(flavor = "current_thread")]
17004    async fn managed_partial_eof_fails_buffered_and_streamed_turns_and_records_only_failures() {
17005        if !crate::run_in_isolated_test_process(
17006            "tests::managed_partial_eof_fails_buffered_and_streamed_turns_and_records_only_failures",
17007            "CAR_MANAGED_PARTIAL_EOF_CHILD",
17008        ) {
17009            return;
17010        }
17011        let _home = crate::openrouter::StateRootScope::new();
17012        use wiremock::matchers::{header, method, path};
17013        use wiremock::{Mock, MockServer, ResponseTemplate};
17014
17015        let _provider_env = crate::openrouter::test_environment_scope_async().await;
17016        let _env = ENV_MUTEX.lock().await;
17017        let bearer = "managed-partial-outcome-bearer";
17018
17019        let server = MockServer::start().await;
17020        unsafe {
17021            std::env::set_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV, bearer);
17022            std::env::set_var(car_auth::PARSLEE_API_BASE_KEY, server.uri());
17023        }
17024        Mock::given(method("GET"))
17025            .and(path("/api/v1/organizations/me"))
17026            .and(header("authorization", format!("Bearer {bearer}")))
17027            .respond_with(
17028                ResponseTemplate::new(200)
17029                    .set_body_json(serde_json::json!({"organizationId": "org-partial-outcome"})),
17030            )
17031            .mount(&server)
17032            .await;
17033        Mock::given(method("GET"))
17034            .and(path("/connect/session"))
17035            .respond_with(
17036                ResponseTemplate::new(200)
17037                    .set_body_json(serde_json::json!({"account": {"email": "user@example.test"}})),
17038            )
17039            .mount(&server)
17040            .await;
17041        Mock::given(method("POST"))
17042            .and(path("/api/v1/orgs/org-partial-outcome/inference/responses"))
17043            .respond_with(ResponseTemplate::new(200).set_body_raw(
17044                "event: response.output_text.delta\ndata: {\"delta\":\"partial must fail\"}\n\n",
17045                "text/event-stream",
17046            ))
17047            .expect(2)
17048            .mount(&server)
17049            .await;
17050
17051        let tmp = TempDir::new().unwrap();
17052        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17053        let model_id = "parslee/openrouter/frontier-general";
17054        let schema = crate::openrouter::curated_schemas()
17055            .into_iter()
17056            .find(|schema| schema.id == model_id)
17057            .unwrap();
17058        engine.unified_registry.register_project_model(schema);
17059
17060        let buffered_error = engine
17061            .generate_tracked(GenerateRequest {
17062                prompt: "buffered".into(),
17063                model: Some(model_id.into()),
17064                params: GenerateParams {
17065                    strict_model: true,
17066                    ..Default::default()
17067                },
17068                ..Default::default()
17069            })
17070            .await
17071            .expect_err("buffered partial EOF must fail");
17072        assert!(
17073            buffered_error.to_string().contains("response.completed"),
17074            "unexpected buffered error: {buffered_error}"
17075        );
17076
17077        let mut stream = engine
17078            .generate_tracked_stream(GenerateRequest {
17079                prompt: "streamed".into(),
17080                model: Some(model_id.into()),
17081                params: GenerateParams {
17082                    strict_model: true,
17083                    ..Default::default()
17084                },
17085                ..Default::default()
17086            })
17087            .await
17088            .expect("HTTP streaming request starts");
17089        let mut events = Vec::new();
17090        while let Some(event) = stream.events.recv().await {
17091            events.push(event);
17092        }
17093        assert!(
17094            matches!(events.last(), Some(StreamEvent::Error(message)) if message.contains("response.completed"))
17095        );
17096        assert!(!events
17097            .iter()
17098            .any(|event| matches!(event, StreamEvent::Done { .. })));
17099
17100        for _ in 0..50 {
17101            if engine
17102                .outcome_tracker()
17103                .read()
17104                .await
17105                .profile(model_id)
17106                .is_some_and(|profile| profile.fail_count == 2)
17107            {
17108                break;
17109            }
17110            tokio::task::yield_now().await;
17111        }
17112        let profile = engine
17113            .outcome_tracker()
17114            .read()
17115            .await
17116            .profile(model_id)
17117            .cloned()
17118            .unwrap();
17119        assert_eq!(profile.fail_count, 2);
17120        assert_eq!(profile.success_count, 0);
17121
17122        unsafe {
17123            std::env::remove_var(crate::remote::PARSLEE_ACCESS_TOKEN_ENV);
17124            std::env::remove_var(car_auth::PARSLEE_API_BASE_KEY);
17125        }
17126    }
17127
17128    #[tokio::test]
17129    async fn tokenize_rejects_known_remote_model_with_unsupported_mode() {
17130        // The unified registry's built-in catalog includes remote models like
17131        // OpenAI / Anthropic ones. Regardless of which exact id ships, we just
17132        // need any non-local schema to confirm the pre-flight catches it
17133        // before we try (and fail) to load a non-existent local backend.
17134        let tmp = TempDir::new().unwrap();
17135        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17136        let remote_id = engine
17137            .list_schemas()
17138            .into_iter()
17139            .find(|s| !s.is_local())
17140            .map(|s| s.id)
17141            .expect("built-in catalog should include at least one remote model schema");
17142
17143        let err = engine
17144            .tokenize(&remote_id, "hello")
17145            .await
17146            .expect_err("remote tokenize must error");
17147        match err {
17148            InferenceError::UnsupportedMode { mode, backend, .. } => {
17149                assert_eq!(mode, "tokenize/detokenize");
17150                assert_eq!(backend, "remote");
17151            }
17152            other => panic!("expected UnsupportedMode, got {other:?}"),
17153        }
17154
17155        let err = engine
17156            .detokenize(&remote_id, &[1, 2, 3])
17157            .await
17158            .expect_err("remote detokenize must error");
17159        assert!(
17160            matches!(err, InferenceError::UnsupportedMode { .. }),
17161            "expected UnsupportedMode, got {err:?}"
17162        );
17163    }
17164
17165    #[test]
17166    fn unsupported_mode_does_not_trip_circuit_breaker() {
17167        // A deterministic capability mismatch (JsonSchema response_format on
17168        // Anthropic, or a video/audio block on a text-only provider) must NOT
17169        // feed the circuit breaker — it would evict a healthy model for ALL
17170        // traffic. Genuine availability errors still count. This locks the guard
17171        // in the dispatch loop's Err arm.
17172        let unsupported = InferenceError::UnsupportedMode {
17173            mode: "structured-output-json-schema",
17174            backend: "anthropic",
17175            reason: "not wired under the pinned API version",
17176        };
17177        assert!(!error_counts_against_circuit_breaker(&unsupported));
17178        assert!(error_counts_against_circuit_breaker(
17179            &InferenceError::InferenceFailed("API returned 500".into())
17180        ));
17181        assert!(error_counts_against_circuit_breaker(
17182            &InferenceError::InferenceFailed("API returned 429".into())
17183        ));
17184    }
17185
17186    /// Parslee-ai/car#796 — a content refusal must not reach model health.
17187    ///
17188    /// The model handles the same payload correctly when the request gets
17189    /// through; the refusal came from a filter in front of it. Benching the
17190    /// model for that would make an adversarial-safety suite progressively
17191    /// evict the very models it is trying to measure — the suite's whole job is
17192    /// to send input that trips filters.
17193    #[test]
17194    fn a_content_refusal_does_not_trip_the_circuit_breaker() {
17195        let refused = InferenceError::ContentRefused {
17196            provider: "parslee".into(),
17197            kind: Some("invalid_request_error".into()),
17198            code: Some("content_policy_violation".into()),
17199            message: "content refused".into(),
17200        };
17201        assert!(!error_counts_against_circuit_breaker(&refused));
17202
17203        // The rendering must carry the classification, since that is what lets
17204        // a benchmark score a refusal apart from a crash.
17205        let rendered = refused.to_string();
17206        assert!(rendered.contains("content grounds"), "{rendered}");
17207        assert!(rendered.contains("content_policy_violation"), "{rendered}");
17208
17209        // A generic failure still counts — the exclusion must be narrow.
17210        assert!(error_counts_against_circuit_breaker(
17211            &InferenceError::InferenceFailed("managed inference failed".into())
17212        ));
17213    }
17214
17215    /// Parslee-ai/car#796 — a content refusal ENDS the chain rather than being
17216    /// answered by the next candidate.
17217    ///
17218    /// The chain cannot vary the request, so every remaining candidate replays
17219    /// the payload the filter just declined — and the tail of a remote-only
17220    /// chain is an appended on-device model with no filter in front of it. Left
17221    /// to fall through, a refused `parslee/reasoning` call returns a *local*
17222    /// model's answer under the requested model's name, which is what makes an
17223    /// adversarial-safety benchmark's counts move run to run.
17224    #[test]
17225    fn a_content_refusal_ends_the_fallback_chain() {
17226        let refused = InferenceError::ContentRefused {
17227            provider: "parslee".into(),
17228            kind: Some("invalid_request_error".into()),
17229            code: Some("content_policy_violation".into()),
17230            message: "content refused".into(),
17231        };
17232        assert!(error_ends_fallback_chain(&refused));
17233
17234        // Narrow, and in the safe direction: every condition that IS about a
17235        // lane keeps advancing the chain, or a single dead credential would
17236        // start failing calls that a fallback would have served.
17237        for still_advances in [
17238            InferenceError::InferenceFailed("managed inference failed".into()),
17239            InferenceError::InferenceFailed("API returned 503".into()),
17240            InferenceError::ModelNotFound("parslee/reasoning".into()),
17241            InferenceError::UnsupportedMode {
17242                mode: "json_schema",
17243                backend: "anthropic",
17244                reason: "structured output is not supported by this protocol",
17245            },
17246            InferenceError::GatewayUnconfigured {
17247                provider: "parslee".into(),
17248                namespace: "parslee/openrouter/".into(),
17249                status: 503,
17250                message: "not configured".into(),
17251            },
17252        ] {
17253            assert!(
17254                !error_ends_fallback_chain(&still_advances),
17255                "must keep advancing the chain: {still_advances}"
17256            );
17257        }
17258    }
17259
17260    /// Parslee-ai/car#796 — the exhausted-chain recovery hints must not launder
17261    /// a content refusal back into a generic failure.
17262    ///
17263    /// Both hints match SUBSTRINGS of the Display text, and a refusal embeds the
17264    /// gateway's own message verbatim. A gateway that says "403 forbidden" while
17265    /// refusing on content grounds would otherwise be rewritten to
17266    /// `InferenceFailed` and lose the classification one layer after it was
17267    /// finally earned.
17268    #[test]
17269    fn recovery_hints_do_not_rewrite_a_content_refusal() {
17270        let refused = InferenceError::ContentRefused {
17271            provider: "parslee".into(),
17272            kind: Some("invalid_request_error".into()),
17273            code: Some("content_policy_violation".into()),
17274            // Deliberately quotes a phrase the auth-expired hint matches on.
17275            message: "blocked: 403 forbidden by the content filter".into(),
17276        };
17277        let out = apply_exhaustion_recovery_hint(refused);
17278        assert!(
17279            matches!(out, InferenceError::ContentRefused { .. }),
17280            "{out:?}"
17281        );
17282
17283        // The hints still fire for the cases they were written for.
17284        let signed_out = apply_exhaustion_recovery_hint(InferenceError::InferenceFailed(
17285            "no credential for proprietary provider 'parslee'".into(),
17286        ));
17287        assert!(
17288            matches!(signed_out, InferenceError::InferenceFailed(ref m) if m.contains("car auth")),
17289            "{signed_out:?}"
17290        );
17291
17292        // ...and an unrelated failure still passes through untouched.
17293        let transient = apply_exhaustion_recovery_hint(InferenceError::InferenceFailed(
17294            "API returned 500".into(),
17295        ));
17296        assert_eq!(transient.to_string(), "inference failed: API returned 500");
17297    }
17298
17299    /// Parslee-ai/car#786 — an unconfigured gateway namespace must not reach
17300    /// per-model health.
17301    ///
17302    /// The measured cost of it doing so: ten `parslee/openrouter/*` aliases
17303    /// sitting at 52 calls / 0 successes in `car models stats`, a health record
17304    /// earned entirely by a deployment that had no upstream to proxy to. The
17305    /// models never ran.
17306    #[test]
17307    fn unconfigured_gateway_does_not_trip_circuit_breaker() {
17308        let unconfigured = InferenceError::GatewayUnconfigured {
17309            provider: "parslee".into(),
17310            namespace: "parslee/openrouter/".into(),
17311            status: 503,
17312            message: "OpenRouter inference is not configured on this Parslee environment.".into(),
17313        };
17314        assert!(!error_counts_against_circuit_breaker(&unconfigured));
17315        // The error must still name the namespace and the remedy-relevant
17316        // detail — a caller that cannot tell WHICH namespace died learns
17317        // nothing the generic failure did not already tell them.
17318        let rendered = unconfigured.to_string();
17319        assert!(rendered.contains("parslee/openrouter/"), "{rendered}");
17320        assert!(rendered.contains("not configured"), "{rendered}");
17321    }
17322
17323    /// The namespace drop must be exact-prefix, not "anything mentioning
17324    /// parslee". Dropping `parslee/reasoning` on an OpenRouter-namespace
17325    /// failure would remove working models from the chain — the usable
17326    /// remainder in car#786 was precisely `parslee/advisor`,
17327    /// `parslee/reasoning`, and `parslee/fast`.
17328    #[test]
17329    fn namespace_drop_spares_siblings_outside_the_prefix() {
17330        let namespace = "parslee/openrouter/";
17331        let mut queue: std::collections::VecDeque<String> = [
17332            "parslee/openrouter/open-fast",
17333            "parslee/reasoning",
17334            "parslee/openrouter/frontier-general",
17335            "parslee/advisor",
17336            "anthropic/claude-opus-4-8:latest",
17337        ]
17338        .into_iter()
17339        .map(String::from)
17340        .collect();
17341
17342        queue.retain(|id| !id.starts_with(namespace));
17343
17344        assert_eq!(
17345            queue.iter().collect::<Vec<_>>(),
17346            vec![
17347                "parslee/reasoning",
17348                "parslee/advisor",
17349                "anthropic/claude-opus-4-8:latest"
17350            ],
17351            "only the unconfigured namespace may be dropped"
17352        );
17353    }
17354
17355    #[test]
17356    fn engine_loads_benchmark_priors_on_startup() {
17357        let _env = ENV_MUTEX.blocking_lock();
17358        let tmp = TempDir::new().unwrap();
17359        let priors_path = tmp.path().join("benchmark_priors.json");
17360        std::fs::write(
17361            &priors_path,
17362            serde_json::json!({
17363                "model_id": "qwen/qwen3-8b:q4_k_m",
17364                "overall_score": 0.88
17365            })
17366            .to_string(),
17367        )
17368        .unwrap();
17369
17370        unsafe {
17371            std::env::set_var("CAR_BENCHMARK_PRIORS_PATH", &priors_path);
17372        }
17373
17374        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17375        let tracker = engine.outcome_tracker.blocking_read();
17376        let profile = tracker
17377            .profile("qwen/qwen3-8b:q4_k_m")
17378            .expect("benchmark prior should create a profile");
17379        assert!((profile.ema_quality - 0.88).abs() < 0.01);
17380
17381        unsafe {
17382            std::env::remove_var("CAR_BENCHMARK_PRIORS_PATH");
17383        }
17384    }
17385
17386    #[test]
17387    fn benchmark_priors_do_not_override_observed_profiles() {
17388        let _env = ENV_MUTEX.blocking_lock();
17389        let tmp = TempDir::new().unwrap();
17390        let models_dir = tmp.path().join("models");
17391        std::fs::create_dir_all(&models_dir).unwrap();
17392
17393        let observed = vec![ModelProfile {
17394            model_id: "qwen/qwen3-8b:q4_k_m".into(),
17395            total_calls: 12,
17396            success_count: 3,
17397            fail_count: 9,
17398            total_latency_ms: 1200,
17399            total_input_tokens: 0,
17400            total_output_tokens: 0,
17401            total_cache_read_input_tokens: 0,
17402            total_cache_creation_input_tokens: 0,
17403            task_stats: std::collections::HashMap::new(),
17404            ema_quality: 0.21,
17405            prior_sample_size: 0,
17406            quality_observations: 0,
17407            quality_per_1k_tokens: 0.0,
17408            updated_at: 1,
17409        }];
17410        std::fs::write(
17411            models_dir.join("outcome_profiles.json"),
17412            serde_json::to_string(&observed).unwrap(),
17413        )
17414        .unwrap();
17415
17416        let priors_path = tmp.path().join("benchmark_priors.json");
17417        std::fs::write(
17418            &priors_path,
17419            serde_json::json!({
17420                "model_id": "qwen/qwen3-8b:q4_k_m",
17421                "overall_score": 0.95
17422            })
17423            .to_string(),
17424        )
17425        .unwrap();
17426
17427        unsafe {
17428            std::env::set_var("CAR_BENCHMARK_PRIORS_PATH", &priors_path);
17429        }
17430
17431        let engine = InferenceEngine::new(test_config(models_dir));
17432        let tracker = engine.outcome_tracker.blocking_read();
17433        let profile = tracker
17434            .profile("qwen/qwen3-8b:q4_k_m")
17435            .expect("observed profile should remain present");
17436        assert!((profile.ema_quality - 0.21).abs() < 0.01);
17437        assert_eq!(profile.total_calls, 12);
17438
17439        unsafe {
17440            std::env::remove_var("CAR_BENCHMARK_PRIORS_PATH");
17441        }
17442    }
17443
17444    #[test]
17445    fn speech_runtime_package_spec_defaults_and_overrides() {
17446        let _env = ENV_MUTEX.blocking_lock();
17447        unsafe {
17448            std::env::remove_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC");
17449        }
17450        assert_eq!(speech_runtime_mlx_audio_spec(), "mlx-audio==0.4.2");
17451
17452        unsafe {
17453            std::env::set_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC", "mlx-audio==0.4.1");
17454        }
17455        assert_eq!(speech_runtime_mlx_audio_spec(), "mlx-audio==0.4.1");
17456
17457        unsafe {
17458            std::env::remove_var("CAR_SPEECH_RUNTIME_MLX_AUDIO_SPEC");
17459        }
17460    }
17461
17462    #[test]
17463    fn speech_runtime_spacy_model_spec_defaults_and_overrides() {
17464        let _env = ENV_MUTEX.blocking_lock();
17465        unsafe {
17466            std::env::remove_var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC");
17467        }
17468        assert!(
17469            speech_runtime_spacy_model_spec().starts_with("en-core-web-sm @ https://github.com/")
17470        );
17471
17472        unsafe {
17473            std::env::set_var(
17474                "CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC",
17475                "en-core-web-sm==3.8.0",
17476            );
17477        }
17478        assert_eq!(speech_runtime_spacy_model_spec(), "en-core-web-sm==3.8.0");
17479
17480        unsafe {
17481            std::env::remove_var("CAR_SPEECH_RUNTIME_SPACY_MODEL_SPEC");
17482        }
17483    }
17484
17485    #[test]
17486    fn kokoro_runtime_fallback_defaults_on() {
17487        unsafe {
17488            std::env::remove_var("CAR_SPEECH_KOKORO_FALLBACK");
17489        }
17490        assert!(kokoro_runtime_fallback_enabled());
17491
17492        unsafe {
17493            std::env::set_var("CAR_SPEECH_KOKORO_FALLBACK", "false");
17494        }
17495        assert!(!kokoro_runtime_fallback_enabled());
17496
17497        unsafe {
17498            std::env::remove_var("CAR_SPEECH_KOKORO_FALLBACK");
17499        }
17500    }
17501
17502    #[test]
17503    fn preferred_local_tts_wins_over_builtin_rank() {
17504        let tmp = TempDir::new().unwrap();
17505        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17506        engine.set_speech_policy(SpeechPolicy {
17507            prefer_local: true,
17508            allow_remote_fallback: false,
17509            preferred_local_stt: None,
17510            preferred_local_tts: Some("Kokoro-82M-6bit".into()),
17511            preferred_remote_stt: None,
17512            preferred_remote_tts: None,
17513        });
17514
17515        let schema = engine
17516            .preferred_speech_schema(ModelCapability::TextToSpeech, true, false)
17517            .expect("preferred local TTS should resolve");
17518        // On macOS/Linux the MLX Kokoro is available (or tied-unavailable with the
17519        // other local TTS), so the policy preference beats the builtin bf16>6bit
17520        // rank — the property under test. On Windows the MLX models are
17521        // unavailable while the OS synthesizer (Windows-Speech) is available, and
17522        // availability precedes policy in the sort: an unavailable *preferred*
17523        // model correctly yields to one that actually runs.
17524        #[cfg(not(target_os = "windows"))]
17525        assert_eq!(schema.name, "Kokoro-82M-6bit");
17526        #[cfg(target_os = "windows")]
17527        assert_eq!(schema.name, "Windows-Speech");
17528    }
17529
17530    #[test]
17531    fn preferred_discovered_vllm_mlx_model_wins_generate_routing() {
17532        let tmp = TempDir::new().unwrap();
17533        let mut config = test_config(tmp.path().join("models"));
17534        config.preferred_generation_model =
17535            Some("vllm-mlx/mlx-community_gemma-3n-E2B-it-lm-4bit".into());
17536        let mut engine = InferenceEngine::new(config);
17537        let schema = crate::vllm_mlx::to_model_schema(
17538            &crate::vllm_mlx::DiscoveredModel {
17539                id: "mlx-community/gemma-3n-E2B-it-lm-4bit".into(),
17540                owned_by: Some("mlx-community".into()),
17541            },
17542            "http://127.0.0.1:8001",
17543        );
17544        engine.register_model(schema);
17545
17546        let rt = tokio::runtime::Runtime::new().unwrap();
17547        let decision = rt.block_on(engine.route_adaptive("say hello in one sentence"));
17548        assert_eq!(
17549            decision.model_id,
17550            "vllm-mlx/mlx-community_gemma-3n-E2B-it-lm-4bit"
17551        );
17552        assert_eq!(decision.strategy, RoutingStrategy::Explicit);
17553        assert_eq!(decision.reason, "preferred generation model override");
17554    }
17555
17556    /// Regression (I4 review, critical 1): the fallback loop was converted
17557    /// from `for` to an index-based `while` whose increment a pre-existing
17558    /// `continue` (e.g. the ToolUse capability guard) skipped — retrying
17559    /// the SAME candidate forever at 100% CPU. The loop is now a pop-front
17560    /// queue, so `continue` always moves on. This pins termination: a
17561    /// tools request routed to a model without ToolUse must RETURN (the
17562    /// capability guard fires, the queue drains, all-models-failed), not
17563    /// hang. Under the buggy loop this test times out.
17564    #[test]
17565    fn tools_request_on_non_tool_model_terminates_not_spins() {
17566        let tmp = TempDir::new().unwrap();
17567        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17568        // "embed" in the name → capabilities [Embed] only (no ToolUse).
17569        let schema = crate::vllm_mlx::to_model_schema(
17570            &crate::vllm_mlx::DiscoveredModel {
17571                id: "test-org/embed-only-model".into(),
17572                owned_by: None,
17573            },
17574            "http://127.0.0.1:1", // nothing listens; must not matter
17575        );
17576        let model_id = schema.id.clone();
17577        engine.register_model(schema);
17578
17579        let mut req = GenerateRequest {
17580            prompt: "call a tool".to_string(),
17581            model: Some(model_id),
17582            tools: Some(vec![serde_json::json!({
17583                "name": "noop", "description": "n", "parameters": {"type": "object"}
17584            })]),
17585            ..Default::default()
17586        };
17587        req.params.strict_model = true;
17588
17589        let rt = tokio::runtime::Runtime::new().unwrap();
17590        let out = rt.block_on(async {
17591            tokio::time::timeout(
17592                std::time::Duration::from_secs(10),
17593                engine.generate_tracked(req),
17594            )
17595            .await
17596        });
17597        // The point is termination; the result is an error (no capable
17598        // model), which is fine.
17599        let completed = out.expect("fallback loop must terminate, not spin");
17600        assert!(completed.is_err());
17601    }
17602
17603    /// Lay down a runtime root that [`SpeechRuntime::is_ready`] accepts, so
17604    /// `prepare_speech_runtime` short-circuits instead of shelling out to `uv`
17605    /// for a real (multi-minute, network-bound) venv + pip install.
17606    ///
17607    /// The interpreter half comes from [`managed_venv::seed_ready_venv`] — one
17608    /// definition, shared with `car-cli`'s CLI tests — because readiness
17609    /// *executes* the interpreter and the runnable-stub trick differs per
17610    /// platform. The console scripts are only stat-ed, so empty files at the
17611    /// paths `SpeechRuntime` itself computes are enough; taking them from the
17612    /// struct is what keeps fixture and probe from drifting apart again.
17613    fn fake_ready_speech_runtime(root: &Path) {
17614        managed_venv::seed_ready_venv(root);
17615        let runtime = SpeechRuntime::new(root.to_path_buf());
17616        for program in [&runtime.stt_program, &runtime.tts_program] {
17617            std::fs::create_dir_all(program.parent().expect("program has a parent")).unwrap();
17618            std::fs::write(program, b"").unwrap();
17619        }
17620    }
17621
17622    /// Parslee-ai/car#649 — `speech install` and `speech doctor` contradicted
17623    /// each other on Apple Silicon: prepare returned `models_dir` (a path
17624    /// doctor never mentions) after skipping provisioning entirely, so install
17625    /// printed "ready" while doctor printed `Installed: no` against a different
17626    /// root. Prepare must hand back exactly the root doctor reports, and that
17627    /// root must exist (Parslee-ai/car#626 — "prepare" leaves the thing
17628    /// prepared).
17629    ///
17630    /// Unscoped by cfg on purpose: the whole point of the fix is that both
17631    /// branches now agree on one root. The pre-seeded runtime keeps `uv` out of
17632    /// it, which is what previously forced this test to be macOS-only.
17633    ///
17634    /// Keeping it unscoped is also what caught the Windows layout bug. When the
17635    /// probe read `<root>/bin/python` on every platform, this test's pre-seeded
17636    /// runtime went unrecognised on Windows, prepare fell through to a real
17637    /// `uv` bootstrap, and CI panicked with "`uv` … was not found on PATH".
17638    ///
17639    /// #953 gated this `#[cfg(unix)]` to unbreak the Windows leg, and named the
17640    /// real repair in the same breath: "Making it spawn means teaching
17641    /// `interpreter` the Windows `Scripts\\python.exe` layout … That is a
17642    /// product change, not a test fix." That product change is now made, so the
17643    /// gate comes back off. It has to: `managed_venv`'s claim that these Python
17644    /// stacks are Apple-Silicon-only holds for the *visual* runtime and is
17645    /// backwards for the speech one, which exists precisely for machines
17646    /// without Apple's MLX backends. Gate this and the runtime loses coverage
17647    /// on its own target platform.
17648    #[tokio::test]
17649    async fn prepare_speech_runtime_returns_the_root_doctor_reports() {
17650        let _env = ENV_MUTEX.lock().await;
17651        let tmp = TempDir::new().unwrap();
17652        let runtime_root = tmp.path().join("speech-runtime");
17653        fake_ready_speech_runtime(&runtime_root);
17654        unsafe {
17655            std::env::set_var("CAR_SPEECH_RUNTIME_DIR", &runtime_root);
17656        }
17657
17658        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17659        let root = engine
17660            .prepare_speech_runtime()
17661            .await
17662            .expect("prepare should succeed against a ready runtime");
17663        let health = engine.speech_health();
17664
17665        assert_eq!(
17666            root, health.runtime.root,
17667            "prepare returned a different root than doctor reports"
17668        );
17669        assert!(
17670            root.exists(),
17671            "prepare returned {} but it does not exist",
17672            root.display()
17673        );
17674        assert!(
17675            health.runtime.installed,
17676            "doctor should report a ready runtime as installed"
17677        );
17678        // Idempotent — a second call on a provisioned runtime is fine.
17679        assert_eq!(
17680            engine
17681                .prepare_speech_runtime()
17682                .await
17683                .expect("second prepare should succeed"),
17684            root
17685        );
17686
17687        unsafe {
17688            std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
17689        }
17690    }
17691
17692    /// Parslee-ai/car#649 — on Apple Silicon the managed runtime is a *fallback*
17693    /// behind working native MLX backends, so a machine without `uv` must still
17694    /// get a usable install: prepare degrades (warns, returns the created root)
17695    /// instead of failing, and doctor is left to report the truth. Elsewhere the
17696    /// managed runtime is the only local speech path and the error propagates.
17697    ///
17698    /// The bogus `CAR_SPEECH_PYTHON` makes the bootstrap fail immediately —
17699    /// `uv venv --python <nonexistent>` cannot resolve an interpreter — so this
17700    /// never runs a real provision, whether or not `uv` is on PATH.
17701    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
17702    #[tokio::test]
17703    async fn prepare_speech_runtime_degrades_when_bootstrap_fails() {
17704        let _env = ENV_MUTEX.lock().await;
17705        let tmp = TempDir::new().unwrap();
17706        let runtime_root = tmp.path().join("speech-runtime");
17707        assert!(!runtime_root.exists(), "precondition: root absent");
17708        unsafe {
17709            std::env::set_var("CAR_SPEECH_RUNTIME_DIR", &runtime_root);
17710            std::env::set_var("CAR_SPEECH_PYTHON", tmp.path().join("no-such-python"));
17711        }
17712
17713        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17714        let root = engine
17715            .prepare_speech_runtime()
17716            .await
17717            .expect("a failed bootstrap must degrade, not fail, on Apple Silicon");
17718        let health = engine.speech_health();
17719
17720        assert_eq!(root, health.runtime.root);
17721        assert!(
17722            root.exists(),
17723            "prepare returned {} but it does not exist",
17724            root.display()
17725        );
17726        assert!(
17727            !health.runtime.installed,
17728            "doctor must not claim a runtime the bootstrap never built"
17729        );
17730
17731        unsafe {
17732            std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
17733            std::env::remove_var("CAR_SPEECH_PYTHON");
17734        }
17735    }
17736
17737    /// car#678: off Apple Silicon the managed mlx-audio runtime cannot be
17738    /// built (`mlx` publishes no Windows/Linux wheels, and `uv` is often
17739    /// absent), and `install_curated_speech` propagated that failure with `?`.
17740    /// So `car speech install` — the command `car speech doctor` explicitly
17741    /// tells those users to run — aborted before the whisper.cpp block, and
17742    /// never fetched the one local speech model that does run there.
17743    ///
17744    /// Linux-only, and deliberately so on both counts. It is a platform where
17745    /// the bug is real, unlike Apple Silicon. And the seam is `HOME`: the
17746    /// whisper cache resolves through `dirs::home_dir()`, which honours `HOME`
17747    /// on Linux but reads a known-folder API on Windows, so redirecting the
17748    /// cache — and with it keeping this test offline — only works here.
17749    #[cfg(target_os = "linux")]
17750    #[tokio::test]
17751    async fn a_runtime_that_cannot_be_built_no_longer_blocks_the_whisper_install() {
17752        let _env = ENV_MUTEX.lock().await;
17753        let tmp = TempDir::new().unwrap();
17754
17755        // Pre-seed the whisper cache so `ensure_model` short-circuits. The
17756        // assertion is that the install *reaches* the download, not that it
17757        // performs one — a 574 MB fetch has no place in a unit test.
17758        let cached = tmp
17759            .path()
17760            .join(".tokhn")
17761            .join("whisper")
17762            .join("ggml-large-v3-turbo-q5_0.bin");
17763        std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
17764        std::fs::write(&cached, b"stand-in for the ggml weights").unwrap();
17765
17766        let previous_home = std::env::var_os("HOME");
17767        unsafe {
17768            std::env::set_var("HOME", tmp.path());
17769            std::env::set_var("CAR_SPEECH_RUNTIME_DIR", tmp.path().join("speech-runtime"));
17770            // `uv venv --python <nonexistent>` cannot resolve an interpreter,
17771            // so the bootstrap fails immediately and never provisions for real
17772            // — whether or not `uv` happens to be on PATH.
17773            std::env::set_var("CAR_SPEECH_PYTHON", tmp.path().join("no-such-python"));
17774        }
17775
17776        let mut engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17777        let installed = engine.install_curated_speech().await;
17778
17779        let restore = || unsafe {
17780            match &previous_home {
17781                Some(value) => std::env::set_var("HOME", value),
17782                None => std::env::remove_var("HOME"),
17783            }
17784            std::env::remove_var("CAR_SPEECH_RUNTIME_DIR");
17785            std::env::remove_var("CAR_SPEECH_PYTHON");
17786        };
17787        let installed = match installed {
17788            Ok(installed) => installed,
17789            Err(error) => {
17790                restore();
17791                panic!("a runtime that cannot be built must not abort the install: {error}");
17792            }
17793        };
17794        let runtime_installed = engine.speech_health().runtime.installed;
17795        restore();
17796
17797        assert!(
17798            !runtime_installed,
17799            "precondition: the bootstrap must actually have failed, or this \
17800             test would pass without exercising anything"
17801        );
17802        let whisper = installed
17803            .iter()
17804            .find(|report| report.hf_repo == "ggerganov/whisper.cpp")
17805            .unwrap_or_else(|| {
17806                panic!(
17807                    "the cross-platform whisper model must still be installed, got: {installed:?}"
17808                )
17809            });
17810        assert_eq!(whisper.snapshot_path, cached);
17811        assert!(
17812            installed
17813                .iter()
17814                .all(|report| report.hf_repo == "ggerganov/whisper.cpp"),
17815            "MLX weights are Apple-only and must not be pulled here — over a \
17816             gigabyte of them, for models this host can never load; got: {installed:?}"
17817        );
17818    }
17819
17820    /// Regression: the non-streaming `generate_tracked` fallback loop must book
17821    /// outcomes against the *resolved canonical id* (`schema.id`), not the raw
17822    /// alias the caller passed. Otherwise an explicit alias like
17823    /// `claude-sonnet-4-6` and the catalog id `anthropic/claude-sonnet-4-6:latest`
17824    /// fragment the model-health surface into two "models" for one physical
17825    /// model. This locks the resolution chain the loop relies on
17826    /// (`get().or_else(find_by_name())` → `schema.id`) against catalog drift.
17827    #[test]
17828    fn alias_resolves_to_canonical_id_for_outcome_keying() {
17829        let tmp = TempDir::new().unwrap();
17830        let engine = InferenceEngine::new(test_config(tmp.path().join("models")));
17831
17832        // (alias passed by a caller, canonical id the outcome tracker must key on)
17833        let cases = [
17834            ("claude-sonnet-4-6", "anthropic/claude-sonnet-4-6:latest"),
17835            ("gpt-5.4", "openai/gpt-5.4:latest"),
17836            ("gemini-2.5-flash", "google/gemini-2.5-flash:latest"),
17837        ];
17838        for (alias, canonical) in cases {
17839            // Exactly the resolution the fallback loop performs before
17840            // `record_start` (see the `resolved_id` binding in the loop).
17841            let resolved = engine
17842                .unified_registry
17843                .get(alias)
17844                .or_else(|| engine.unified_registry.find_by_name(alias))
17845                .map(|s| s.id.clone())
17846                .unwrap_or_else(|| alias.to_string());
17847            assert_eq!(
17848                resolved, canonical,
17849                "alias `{alias}` must resolve to canonical `{canonical}` for outcome keying, got `{resolved}`"
17850            );
17851            assert_ne!(
17852                resolved, alias,
17853                "alias `{alias}` must NOT be recorded raw — that is the fragmentation bug"
17854            );
17855        }
17856    }
17857
17858    /// Issue #43 — InferenceResult must serialize with all fields preserved
17859    /// (text, tool_calls, trace_id, model_used, latency_ms, usage) using
17860    /// snake_case field names. The car-server WebSocket handler relies on
17861    /// `serde_json::to_value(&InferenceResult)` producing this exact shape.
17862    #[test]
17863    fn inference_result_serializes_with_full_shape() {
17864        use crate::tasks::generate::ToolCall;
17865        use std::collections::HashMap;
17866
17867        let mut args = HashMap::new();
17868        args.insert("path".to_string(), serde_json::json!("README.md"));
17869
17870        let result = InferenceResult {
17871            text: String::new(),
17872            bounding_boxes: Vec::new(),
17873            tool_calls: vec![ToolCall {
17874                id: None,
17875                name: "read_file".into(),
17876                arguments: args,
17877            }],
17878            trace_id: "trace-abc".into(),
17879            model_used: "test-model".into(),
17880            model_identity: InferenceModelIdentity {
17881                requested_model_id: Some("test/model:1".into()),
17882                resolved_model_id: "test/model:1".into(),
17883                row_digest: "a".repeat(64),
17884                catalog_revision: "b".repeat(64),
17885            },
17886            latency_ms: 1234,
17887            time_to_first_token_ms: Some(180),
17888            usage: Some(TokenUsage {
17889                prompt_tokens: 100,
17890                completion_tokens: 50,
17891                total_tokens: 150,
17892                context_window: 8192,
17893                ..Default::default()
17894            }),
17895            provider_output_items: Vec::new(),
17896            thinking: Vec::new(),
17897            stop_reason: Some("tool_use".into()),
17898            auth_fallback_from: None,
17899            local_last_resort: false,
17900            fallback_from: Vec::new(),
17901        };
17902
17903        let json = serde_json::to_value(&result).expect("serialize");
17904
17905        // stop_reason propagates through serialization when populated.
17906        assert_eq!(json["stop_reason"].as_str(), Some("tool_use"));
17907
17908        // Required snake_case fields with type-strict assertions
17909        assert_eq!(json["text"].as_str(), Some(""));
17910        assert_eq!(json["trace_id"].as_str(), Some("trace-abc"));
17911        assert_eq!(json["model_used"].as_str(), Some("test-model"));
17912        assert_eq!(json["requested_model_id"].as_str(), Some("test/model:1"));
17913        assert_eq!(json["resolved_model_id"].as_str(), Some("test/model:1"));
17914        assert_eq!(json["row_digest"].as_str().unwrap(), "a".repeat(64));
17915        assert_eq!(json["catalog_revision"].as_str().unwrap(), "b".repeat(64));
17916        assert_eq!(json["latency_ms"].as_u64(), Some(1234));
17917
17918        // tool_calls is a non-empty array with name + arguments
17919        let tool_calls = json["tool_calls"].as_array().expect("tool_calls array");
17920        assert_eq!(tool_calls.len(), 1);
17921        assert_eq!(tool_calls[0]["name"].as_str(), Some("read_file"));
17922        assert_eq!(
17923            tool_calls[0]["arguments"]["path"].as_str(),
17924            Some("README.md")
17925        );
17926
17927        // usage is an object with all four documented fields
17928        let usage = &json["usage"];
17929        assert_eq!(usage["prompt_tokens"].as_u64(), Some(100));
17930        assert_eq!(usage["completion_tokens"].as_u64(), Some(50));
17931        assert_eq!(usage["total_tokens"].as_u64(), Some(150));
17932        assert_eq!(usage["context_window"].as_u64(), Some(8192));
17933
17934        // TTFT propagates through serialization when populated.
17935        assert_eq!(json["time_to_first_token_ms"].as_u64(), Some(180));
17936    }
17937
17938    /// Issue #43 — Lock the top-level WebSocket `infer` response contract.
17939    /// If a future change adds a field to `InferenceResult`, this test forces
17940    /// the developer to deliberately update the protocol surface and the
17941    /// expected key set here, rather than silently leaking new fields onto
17942    /// the wire.
17943    #[test]
17944    fn inference_result_top_level_keys_are_locked() {
17945        use std::collections::BTreeSet;
17946
17947        let result = InferenceResult {
17948            text: "anything".into(),
17949            bounding_boxes: Vec::new(),
17950            tool_calls: vec![],
17951            trace_id: "t".into(),
17952            model_used: "m".into(),
17953            model_identity: InferenceModelIdentity::default(),
17954            latency_ms: 0,
17955            time_to_first_token_ms: None,
17956            usage: None,
17957            provider_output_items: Vec::new(),
17958            thinking: Vec::new(),
17959            stop_reason: None,
17960            auth_fallback_from: None,
17961            local_last_resort: false,
17962            fallback_from: Vec::new(),
17963        };
17964
17965        let json = serde_json::to_value(&result).expect("serialize");
17966        let keys: BTreeSet<&str> = json
17967            .as_object()
17968            .expect("top-level object")
17969            .keys()
17970            .map(String::as_str)
17971            .collect();
17972
17973        let expected: BTreeSet<&str> = [
17974            "text",
17975            "tool_calls",
17976            "trace_id",
17977            "model_used",
17978            "requested_model_id",
17979            "resolved_model_id",
17980            "row_digest",
17981            "catalog_revision",
17982            "latency_ms",
17983            "time_to_first_token_ms",
17984            "usage",
17985            "stop_reason",
17986        ]
17987        .into_iter()
17988        .collect();
17989
17990        assert_eq!(
17991            keys, expected,
17992            "infer response top-level keys drifted -- update both the test \
17993             and the WebSocket protocol documentation if this is intentional"
17994        );
17995
17996        // All keys are snake_case (constraint c-2 in outcome 043).
17997        for key in &keys {
17998            assert!(
17999                !key.chars().any(|c| c.is_uppercase()) && !key.contains('-'),
18000                "key '{}' is not snake_case",
18001                key
18002            );
18003        }
18004    }
18005
18006    /// Plain text result (no tools) must still serialize cleanly with text
18007    /// populated and tool_calls present as an empty array. Backward compat
18008    /// for clients that only care about `.text`.
18009    #[test]
18010    fn inference_result_serializes_plain_text_response() {
18011        let result = InferenceResult {
18012            text: "hello world".into(),
18013            bounding_boxes: Vec::new(),
18014            tool_calls: vec![],
18015            trace_id: "trace-xyz".into(),
18016            model_used: "test-model".into(),
18017            model_identity: InferenceModelIdentity::default(),
18018            latency_ms: 42,
18019            time_to_first_token_ms: None,
18020            usage: None,
18021            provider_output_items: Vec::new(),
18022            thinking: Vec::new(),
18023            stop_reason: None,
18024            auth_fallback_from: None,
18025            local_last_resort: false,
18026            fallback_from: Vec::new(),
18027        };
18028
18029        let json = serde_json::to_value(&result).expect("serialize");
18030        assert_eq!(json["text"], "hello world");
18031        // Always-present null when the provider didn't report one.
18032        assert!(json["stop_reason"].is_null());
18033        assert!(json["tool_calls"].is_array());
18034        assert_eq!(json["tool_calls"].as_array().unwrap().len(), 0);
18035        assert_eq!(json["model_used"], "test-model");
18036        assert!(json["usage"].is_null());
18037        // Honest "wasn't measured" rather than missing key — the field
18038        // is always present at the protocol surface.
18039        assert!(json["time_to_first_token_ms"].is_null());
18040    }
18041
18042    #[test]
18043    fn append_assistant_history_preserves_responses_items_in_provider_order() {
18044        let reasoning = serde_json::json!({
18045            "type": "reasoning",
18046            "id": "rs_history",
18047            "status": "completed",
18048            "summary": [{"type": "summary_text", "text": "safe"}],
18049            "encrypted_content": "opaque",
18050        });
18051        let result: InferenceResult = serde_json::from_value(serde_json::json!({
18052            "text": "calling",
18053            "tool_calls": [{
18054                "id": "call_1",
18055                "name": "read_file",
18056                "arguments": {"path": "README.md"}
18057            }],
18058            "trace_id": "trace",
18059            "model_used": "gateway-alias",
18060            "resolved_model_id": "openrouter/anthropic/claude-sonnet-4.5",
18061            "local_last_resort": true,
18062            "latency_ms": 1,
18063            "provider_output_items": [reasoning.clone()],
18064        }))
18065        .unwrap();
18066        let mut history = vec![crate::tasks::generate::Message::User {
18067            content: "inspect".into(),
18068        }];
18069
18070        result.append_assistant_history(&mut history, result.tool_calls.clone());
18071
18072        assert!(matches!(
18073            &history[1],
18074            crate::tasks::generate::Message::ProviderOutputItems { protocol, items }
18075                if protocol == crate::protocol::OPENAI_RESPONSES_PROTOCOL
18076                    && items == &vec![reasoning]
18077        ));
18078        assert!(matches!(
18079            &history[2],
18080            crate::tasks::generate::Message::Assistant {
18081                content,
18082                tool_calls,
18083                model_id,
18084                local_last_resort,
18085                ..
18086            } if content == "calling"
18087                && tool_calls[0].id.as_deref() == Some("call_1")
18088                && model_id.as_deref() == Some("openrouter/anthropic/claude-sonnet-4.5")
18089                && *local_last_resort
18090        ));
18091    }
18092
18093    #[test]
18094    fn append_assistant_history_leaves_personal_chat_history_unchanged() {
18095        let result: InferenceResult = serde_json::from_value(serde_json::json!({
18096            "text": "plain",
18097            "tool_calls": [],
18098            "trace_id": "trace",
18099            "model_used": "openrouter/openai/gpt-4.1-mini",
18100            "latency_ms": 1,
18101        }))
18102        .unwrap();
18103        let mut history = Vec::new();
18104
18105        result.append_assistant_history(&mut history, Vec::new());
18106
18107        assert_eq!(history.len(), 1);
18108        assert!(matches!(
18109            &history[0],
18110            crate::tasks::generate::Message::Assistant { content, .. } if content == "plain"
18111        ));
18112    }
18113
18114    /// Wire contract — the WebSocket `infer` handler in
18115    /// `car-server-core/src/handler.rs::handle_infer` deserializes
18116    /// the entire `GenerateRequest` from JSON-RPC params via
18117    /// `serde_json::from_value(msg.params.clone())`. That means the
18118    /// `intent` field must remain a serde-deserialize field of
18119    /// `GenerateRequest` for the WS surface to honor caller-supplied
18120    /// routing intent. If a refactor moves intent to a separate
18121    /// argument or renames the field, this test fails and the WS
18122    /// handler must be updated to thread intent explicitly. See
18123    /// `docs/proposals/policy-intent-surface.md` and
18124    /// `docs/websocket-protocol.md` `infer` section.
18125    #[test]
18126    fn generate_request_deserializes_intent_field_from_json_rpc_params() {
18127        use crate::intent::TaskHint;
18128        use crate::schema::ModelCapability;
18129
18130        // Shape mirrors what a WebSocket client sends in the `params`
18131        // object on an `infer` JSON-RPC method call.
18132        let params = serde_json::json!({
18133            "prompt": "summarize this email",
18134            "intent": {
18135                "task": "chat",
18136                "prefer_local": true,
18137                "require": ["tool_use"],
18138            },
18139        });
18140
18141        let req: GenerateRequest =
18142            serde_json::from_value(params).expect("GenerateRequest deserialize");
18143
18144        let intent = req.intent.as_ref().expect("intent field deserialized");
18145        assert_eq!(intent.task, Some(TaskHint::Chat));
18146        assert!(intent.prefer_local);
18147        assert_eq!(intent.require, vec![ModelCapability::ToolUse]);
18148
18149        // Round-trip through serde_json::to_value to confirm the
18150        // re-encoded shape matches what handle_infer would forward to
18151        // the engine without dropping the field.
18152        let back: serde_json::Value =
18153            serde_json::to_value(&req).expect("re-serialize GenerateRequest");
18154        assert_eq!(back["intent"]["task"], "chat");
18155        assert_eq!(back["intent"]["prefer_local"], true);
18156        assert_eq!(back["intent"]["require"][0], "tool_use");
18157
18158        // Default `IntentHint` (no fields set) maps to the no-intent
18159        // path and must serialize as bare `{}` so missing-keys clients
18160        // see a stable default — same guarantee `intent.rs::tests` has
18161        // for the type itself, repeated here at the request boundary.
18162        let default_req: GenerateRequest = serde_json::from_value(serde_json::json!({
18163            "prompt": "x",
18164            "intent": {},
18165        }))
18166        .unwrap();
18167        let default_intent = default_req.intent.expect("present but empty");
18168        assert_eq!(default_intent.task, None);
18169        assert!(!default_intent.prefer_local);
18170        assert!(default_intent.require.is_empty());
18171
18172        // Missing intent field entirely → `None`, matching pre-intent
18173        // clients exactly. This is the backwards-compat guarantee.
18174        let no_intent: GenerateRequest =
18175            serde_json::from_value(serde_json::json!({"prompt": "x"})).unwrap();
18176        assert!(no_intent.intent.is_none());
18177    }
18178
18179    #[test]
18180    fn rerank_prompt_matches_upstream_template_shape() {
18181        let p = rerank_prompt(
18182            "retrieve relevant passages",
18183            "who runs the treasury?",
18184            "doc x",
18185        );
18186        assert!(p.contains("<|im_start|>system"));
18187        assert!(p.contains("Note that the answer can only be \"yes\" or \"no\"."));
18188        assert!(p.contains("<|im_start|>user\n<Instruct>: retrieve relevant passages"));
18189        assert!(p.contains("<Query>: who runs the treasury?"));
18190        assert!(p.contains("<Document>: doc x<|im_end|>"));
18191        assert!(p.contains("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
18192    }
18193
18194    #[test]
18195    fn rerank_score_yes_and_no_exactly() {
18196        assert_eq!(score_from_rerank_output("yes", "m"), 1.0);
18197        assert_eq!(score_from_rerank_output("no", "m"), 0.0);
18198    }
18199
18200    #[test]
18201    fn rerank_score_handles_case_leading_space_and_chat_sentinels() {
18202        // Real decodes often include leading whitespace, punctuation,
18203        // or chat-template sentinels around the answer token.
18204        assert_eq!(score_from_rerank_output(" Yes", "m"), 1.0);
18205        assert_eq!(score_from_rerank_output("\nno.", "m"), 0.0);
18206        assert_eq!(score_from_rerank_output("<|im_end|>yes", "m"), 1.0);
18207    }
18208
18209    #[test]
18210    fn rerank_score_scans_up_to_three_tokens() {
18211        // Tokenizer artifacts can produce a BOS-like leading token
18212        // before the real answer. Don't miss it.
18213        assert_eq!(score_from_rerank_output("_bos_ yes", "m"), 1.0);
18214    }
18215
18216    #[test]
18217    fn rerank_score_unexpected_is_neutral() {
18218        // Plain-base models that aren't reranker-fine-tuned will emit
18219        // arbitrary completion tokens. Don't partition; go neutral.
18220        assert_eq!(score_from_rerank_output("maybe", "m"), 0.5);
18221        assert_eq!(score_from_rerank_output("", "m"), 0.5);
18222    }
18223
18224    #[tokio::test]
18225    async fn pull_reuses_a_valid_directory_receipt_and_reports_removability() {
18226        let root = tempfile::tempdir().unwrap();
18227        let models_dir = root.path().join("models");
18228        std::fs::create_dir_all(&models_dir).unwrap();
18229        let engine = InferenceEngine::new(InferenceConfig {
18230            state_root: root.path().join("state"),
18231            models_dir: models_dir.clone(),
18232            ..InferenceConfig::default()
18233        });
18234        let schema = engine
18235            .unified_registry
18236            .all()
18237            .find(|schema| matches!(schema.source, ModelSource::Local { .. }))
18238            .unwrap()
18239            .clone();
18240        let managed = models_dir.join(&schema.name);
18241        std::fs::create_dir_all(&managed).unwrap();
18242        std::fs::write(managed.join("model.gguf"), b"owned").unwrap();
18243        std::fs::write(managed.join("tokenizer.json"), b"{}").unwrap();
18244        engine
18245            .model_management
18246            .record_managed_artifact(
18247                &schema.id,
18248                model_source_identity(&schema),
18249                None,
18250                1,
18251                false,
18252                managed.clone(),
18253            )
18254            .unwrap();
18255        let row = engine
18256            .list_models_unified()
18257            .into_iter()
18258            .find(|row| row.id == schema.id)
18259            .unwrap();
18260        if model_management::directory_removal_supported() {
18261            assert!(engine.model_management.can_remove(&schema.id).unwrap());
18262            assert!(row.can_remove);
18263            assert_eq!(row.management_evidence.as_deref(), Some("install_receipt"));
18264        } else {
18265            assert!(!engine.model_management.can_remove(&schema.id).unwrap());
18266            assert!(!row.can_remove);
18267            assert_eq!(
18268                row.management_evidence.as_deref(),
18269                Some("install_receipt_directory_cleanup_unsupported")
18270            );
18271        }
18272
18273        let reused = engine.pull_model(&schema.id).await.unwrap();
18274        assert_eq!(reused, managed);
18275    }
18276
18277    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
18278    #[test]
18279    fn kokoro_cache_is_explicitly_state_root_scoped_and_does_not_pin_runtime() {
18280        let first_root = tempfile::tempdir().unwrap();
18281        let second_root = tempfile::tempdir().unwrap();
18282        let first = scoped_kokoro_backend_cache(first_root.path());
18283        let second = scoped_kokoro_backend_cache(second_root.path());
18284        assert!(!Arc::ptr_eq(first.cache(), second.cache()));
18285        assert!(!Arc::ptr_eq(first.admission(), second.admission()));
18286
18287        let runtime = Arc::downgrade(&first._runtime);
18288        drop(first);
18289        assert!(
18290            runtime.upgrade().is_none(),
18291            "an unused Kokoro accessor must not process-pin the scoped runtime"
18292        );
18293    }
18294}
18295
18296#[cfg(test)]
18297mod response_format_support_tests {
18298    use super::*;
18299
18300    /// The CLI's startup warning asks the engine, and the engine asks the
18301    /// SAME protocol handler the remote path consults — so the answer cannot
18302    /// drift from what a real request would hit.
18303    #[test]
18304    fn rejection_reason_tracks_the_protocol_handler() {
18305        let engine = InferenceEngine::new(Default::default());
18306        let rf = crate::tasks::generate::ResponseFormat::JsonObject;
18307        let models = engine.list_models_unified();
18308        let anthropic = models
18309            .iter()
18310            .find(|m| m.provider.eq_ignore_ascii_case("anthropic"));
18311        if let Some(m) = anthropic {
18312            let reason = engine
18313                .response_format_rejection_reason(&m.id, &rf)
18314                .expect("the Anthropic protocol rejects response_format");
18315            assert!(reason.contains("protocol rejects"), "{reason}");
18316        }
18317        let openrouter = models
18318            .iter()
18319            .find(|m| m.provider.eq_ignore_ascii_case("openrouter"));
18320        if let Some(m) = openrouter {
18321            assert_eq!(
18322                engine.response_format_rejection_reason(&m.id, &rf),
18323                None,
18324                "OpenRouter forwards the format upstream"
18325            );
18326        }
18327        assert!(
18328            anthropic.is_some() || openrouter.is_some(),
18329            "the builtin catalog should list at least one of the two providers this pins"
18330        );
18331        assert_eq!(
18332            engine.response_format_rejection_reason("no/such-model", &rf),
18333            None,
18334            "an unknown model is not a rejection"
18335        );
18336    }
18337}